From 5e980b1e5f7ce17f64b158e07a50cc67b3d2c2b1 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 29 Oct 2026 16:45:02 +0000 Subject: [PATCH 1/5] ci: add a Windows build and test gate The release now cross-compiles windows/amd64 and windows/arm64 (zip archives) from the existing macOS GoReleaser run; the signing hook already passes non-darwin targets through. Windows binaries ship unsigned: there is no Apple-style notarization gate on Windows, only SmartScreen reputation, and wiring Authenticode signing (Trusted Signing or a Key Vault certificate) is a separate decision. A new Windows CI job runs the same make check gate as Linux and macOS. GNU make comes from chocolatey; the Makefile's SHELL=/bin/bash does not resolve on Windows, so the job overrides it with the runner's Git Bash. Making the suite green under that gate required real fixes, not just config: the opencode write-failure test used RLIMIT_FSIZE/SIGXFSZ and now lives behind the unix build tag, and the test suite isolated state with HOME/XDG only, which os.UserHomeDir and os.UserConfigDir ignore on Windows (they read USERPROFILE and APPDATA), so tests would have leaked into the runner profile and broken each other; every isolation site now sets both. The binary-discovery tests also assumed extension-less files are executable; Windows only treats PATHEXT extensions as executable, so the fakes become .bat and the common-bin-dirs fallback test skips on Windows because FindBinary probes those dirs for the bare name, which can never match there. Verified locally: make check green, GOOS=windows go vet clean for both arches, and a goreleaser snapshot produces aperture-cli_windows_{amd64, arm64}.zip containing aperture.exe. The Windows CI job itself can only be exercised by pushing this branch. --- .github/workflows/ci-windows.yaml | 37 ++++++++++++ .goreleaser.yaml | 5 +- README.md | 4 +- internal/cli/cli_test.go | 2 + internal/clients/binary_test.go | 40 ++++++++++--- internal/clients/claudecode/check_test.go | 10 ++++ .../clients/claudecode/claudecode_test.go | 20 ++++++- internal/clients/codex/codex_test.go | 5 +- internal/clients/copilot/copilot_test.go | 5 +- internal/clients/gemini/gemini_test.go | 7 ++- internal/clients/hermes/hermes_test.go | 5 +- internal/clients/opencode/opencode_test.go | 56 ++++-------------- .../opencode_write_failure_unix_test.go | 58 +++++++++++++++++++ internal/clients/pilike/pilike_test.go | 2 + internal/config/runlog_test.go | 4 ++ internal/config/startup_test.go | 2 + internal/config/state_test.go | 30 ++++++++++ internal/tui/tui_test.go | 24 ++++++++ 18 files changed, 252 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/ci-windows.yaml create mode 100644 internal/clients/opencode/opencode_write_failure_unix_test.go diff --git a/.github/workflows/ci-windows.yaml b/.github/workflows/ci-windows.yaml new file mode 100644 index 0000000..a5fc13f --- /dev/null +++ b/.github/workflows/ci-windows.yaml @@ -0,0 +1,37 @@ +name: Windows CI + +on: + push: + branches: ["main"] + + pull_request: + branches: ["main"] + + # Allows manual triggering of the workflow + workflow_dispatch: + +jobs: + run-tests: + name: Go ${{ matrix.go-version }} + runs-on: windows-latest + strategy: + matrix: + go-version: ["1.26.10", "1.27.x"] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: ${{ matrix.go-version }} + + # The Makefile is the one definition of the gate. GNU make is not on + # the runner image, so it comes from chocolatey; the Makefile pins + # SHELL to /bin/bash, which does not resolve on Windows, so the + # command line overrides it with Git Bash from the runner image. + - name: Check + shell: bash + run: | + export PATH="/c/Program Files/Git/bin:/c/Program Files/Git/usr/bin:$PATH" + choco install make --no-progress + make SHELL=bash check diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e3e7f74..5a3429c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,11 +18,12 @@ builds: goos: - linux - darwin + - windows goarch: - amd64 - arm64 # Darwin releases require signatures and notarization. Only snapshots - # may skip these checks; Linux builds pass through unsigned. + # may skip these checks; Linux and Windows builds pass through unsigned. hooks: post: - cmd: 'bash scripts/sign-macos.sh "{{ .Path }}" "{{ .Target }}" "{{ .IsSnapshot }}"' @@ -33,7 +34,7 @@ archives: - tar.gz name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" - # leaving this here for when windows builds are supported + # Windows users expect a zip. format_overrides: - goos: windows formats: diff --git a/README.md b/README.md index 0934ece..324b8a8 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ make build macOS builds are Developer ID signed and notarized. curl and `go install` never set the quarantine attribute, so Gatekeeper stays out of the way. If you download the archive in a browser and double-click the binary instead, macOS may block it anyway (common on managed Macs): open System Settings > Privacy & Security and click **Open Anyway** next to the blocked entry. +Windows builds ship as a zip containing `aperture.exe`; the install script is Unix-only, so on Windows download the archive from the release page or use `go install`. Windows builds are not code-signed yet, so SmartScreen may warn until the binary builds reputation. + ## Usage ```sh @@ -215,7 +217,7 @@ and testing an installed binary with `APERTURE_E2E_BIN`. ## Releasing -Push a tag. The release workflow runs GoReleaser on a macOS runner: it imports the Developer ID certificate from GitHub secrets into a temporary keychain, and a build hook signs and notarizes each darwin binary before archiving, so nothing unsigned is ever published. A failed signature or a rejected notarization fails the run before upload. Required secrets: `APPLE_CERT_P12` (the base64-encoded .p12), `APPLE_CERT_PASSWORD`, `APPLE_ID` and `APPLE_ID_PASSWORD` (an app-specific password). +Push a tag. The release workflow runs GoReleaser on a macOS runner: it imports the Developer ID certificate from GitHub secrets into a temporary keychain, and a build hook signs and notarizes each darwin binary before archiving, so no unsigned darwin build is ever published. Linux and Windows binaries cross-compile from the same run and are published unsigned. A failed signature or a rejected notarization fails the run before upload. Required secrets: `APPLE_CERT_P12` (the base64-encoded .p12), `APPLE_CERT_PASSWORD`, `APPLE_ID` and `APPLE_ID_PASSWORD` (an app-specific password). Local fallback on a Mac that has the certificate and a stored notary profile, for when the workflow could not sign: diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 036faf3..9743055 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -19,6 +19,8 @@ func scratchHome(t *testing.T) { t.Helper() tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) config.UseSettingsFile("") } diff --git a/internal/clients/binary_test.go b/internal/clients/binary_test.go index fc31f16..a06f14d 100644 --- a/internal/clients/binary_test.go +++ b/internal/clients/binary_test.go @@ -3,20 +3,32 @@ package clients_test import ( "os" "path/filepath" + "runtime" "testing" "github.com/tailscale/aperture-cli/internal/clients" ) +// fakeBinary names a fake client binary the way the platform's executability +// rules require: Windows only treats PATHEXT extensions as executable. +func fakeBinary(name string) string { + if runtime.GOOS == "windows" { + return name + ".bat" + } + return name +} + func TestFindBinary_PrefersPath(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) pathBin := filepath.Join(tmp, "pathbin") if err := os.MkdirAll(pathBin, 0o755); err != nil { t.Fatal(err) } - pathBinary := filepath.Join(pathBin, "opencode") + pathBinary := filepath.Join(pathBin, fakeBinary("opencode")) if err := os.WriteFile(pathBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } @@ -25,7 +37,7 @@ func TestFindBinary_PrefersPath(t *testing.T) { if err := os.MkdirAll(commonBin, 0o755); err != nil { t.Fatal(err) } - commonBinary := filepath.Join(commonBin, "opencode") + commonBinary := filepath.Join(commonBin, fakeBinary("opencode")) if err := os.WriteFile(commonBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } @@ -42,26 +54,34 @@ func TestFindBinary_FallbackToExtraPaths(t *testing.T) { tmp := t.TempDir() t.Setenv("PATH", tmp) t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) binDir := filepath.Join(tmp, ".opencode", "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatal(err) } - fakeBinary := filepath.Join(binDir, "opencode") - if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { + fake := filepath.Join(binDir, fakeBinary("opencode")) + if err := os.WriteFile(fake, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } - got := clients.FindBinary("opencode", []string{fakeBinary}) - if got != fakeBinary { - t.Errorf("FindBinary() = %q, want %q", got, fakeBinary) + got := clients.FindBinary("opencode", []string{fake}) + if got != fake { + t.Errorf("FindBinary() = %q, want %q", got, fake) } - if !clients.IsInstalled("opencode", []string{fakeBinary}) { + if !clients.IsInstalled("opencode", []string{fake}) { t.Error("IsInstalled() = false, want true") } } func TestFindBinary_FallbackToCommonBinDirs(t *testing.T) { + if runtime.GOOS == "windows" { + // FindBinary probes the common dirs for the bare name, which Windows + // never treats as executable; only LookPath (PATHEXT) finds clients + // there. + t.Skip("common bin dirs hold no extension-less executables on Windows") + } tmp := t.TempDir() t.Setenv("PATH", tmp) t.Setenv("HOME", tmp) @@ -85,6 +105,8 @@ func TestFindBinary_NotFound(t *testing.T) { tmp := t.TempDir() t.Setenv("PATH", tmp) t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) got := clients.FindBinary("claude", nil) if got != "" { @@ -99,6 +121,8 @@ func TestFindBinary_SkipsNonExecutable(t *testing.T) { tmp := t.TempDir() t.Setenv("PATH", tmp) t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) binDir := filepath.Join(tmp, ".local", "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { diff --git a/internal/clients/claudecode/check_test.go b/internal/clients/claudecode/check_test.go index 69dabed..a81fe6b 100644 --- a/internal/clients/claudecode/check_test.go +++ b/internal/clients/claudecode/check_test.go @@ -10,6 +10,8 @@ import ( func TestCheck_NoSettingsFile(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) if err := checkClaudeSettings(); err != nil { t.Fatalf("Check returned error when settings.json missing: %v", err) } @@ -18,6 +20,8 @@ func TestCheck_NoSettingsFile(t *testing.T) { func TestCheck_NoConflicts(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) claudeDir := filepath.Join(tmp, ".claude") if err := os.MkdirAll(claudeDir, 0o755); err != nil { @@ -35,6 +39,8 @@ func TestCheck_NoConflicts(t *testing.T) { func TestCheck_WithConflicts(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) claudeDir := filepath.Join(tmp, ".claude") if err := os.MkdirAll(claudeDir, 0o755); err != nil { @@ -63,6 +69,8 @@ func TestCheck_WithConflicts(t *testing.T) { func TestCheck_InvalidJSON(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) claudeDir := filepath.Join(tmp, ".claude") if err := os.MkdirAll(claudeDir, 0o755); err != nil { @@ -83,6 +91,8 @@ func TestCheck_InvalidJSON(t *testing.T) { func TestCheck_EmptyEnv(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) claudeDir := filepath.Join(tmp, ".claude") if err := os.MkdirAll(claudeDir, 0o755); err != nil { diff --git a/internal/clients/claudecode/claudecode_test.go b/internal/clients/claudecode/claudecode_test.go index 6ddf791..93065da 100644 --- a/internal/clients/claudecode/claudecode_test.go +++ b/internal/clients/claudecode/claudecode_test.go @@ -3,6 +3,7 @@ package claudecode import ( "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -256,9 +257,19 @@ func TestTierModelEnv_Mantle(t *testing.T) { func TestMantleMenuLaunchAndReplay(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("APPDATA", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) - t.Setenv("PATH", "") bin := filepath.Join(home, ".local", "bin", "claude") + pathEnv := "" + if runtime.GOOS == "windows" { + // Windows only treats PATHEXT extensions as executable and the + // common-dirs fallback probes the bare name, so the fake must ride + // PATH as a .bat. + bin += ".bat" + pathEnv = filepath.Dir(bin) + } + t.Setenv("PATH", pathEnv) if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil { t.Fatal(err) } @@ -388,7 +399,10 @@ func lower(s string) string { } func TestSpec(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) anthropic := config.ProviderInfo{ID: "anthropic", Models: []string{"claude-opus", "claude-sonnet"}, SupportedEndpoints: map[string]bool{config.EndpointAnthropicMessages: true}} bedrock := config.ProviderInfo{ID: "bedrock", Models: []string{"us.anthropic.claude-opus-4"}, SupportedEndpoints: map[string]bool{config.EndpointBedrockInvoke: true}} openai := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5"}, SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}} @@ -479,7 +493,7 @@ func TestSpec(t *testing.T) { } // A conflicting ~/.claude/settings.json blocks the headless launch too. - claudeDir := filepath.Join(os.Getenv("HOME"), ".claude") + claudeDir := filepath.Join(tmp, ".claude") if err := os.MkdirAll(claudeDir, 0o755); err != nil { t.Fatal(err) } diff --git a/internal/clients/codex/codex_test.go b/internal/clients/codex/codex_test.go index ff7f00c..c4eed87 100644 --- a/internal/clients/codex/codex_test.go +++ b/internal/clients/codex/codex_test.go @@ -220,7 +220,10 @@ func TestReplay_StaleProvider(t *testing.T) { // The headless launch (ADR 0007): flags win, LastLaunch fills what they // leave empty, and anything left goes to the first compatible provider. func TestSpec(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) responses := map[string]bool{config.EndpointOpenAIResponses: true} openai := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5", "gpt-5-mini"}, SupportedEndpoints: responses} anthropic := config.ProviderInfo{ID: "anthropic", Models: []string{"claude"}, SupportedEndpoints: map[string]bool{config.EndpointAnthropicMessages: true}} diff --git a/internal/clients/copilot/copilot_test.go b/internal/clients/copilot/copilot_test.go index 5ca581c..53151a1 100644 --- a/internal/clients/copilot/copilot_test.go +++ b/internal/clients/copilot/copilot_test.go @@ -134,7 +134,10 @@ func TestStripProviderPrefix(t *testing.T) { // leave empty, and anything left goes to the first compatible provider on // its first backend. func TestSpec(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) both := map[string]bool{config.EndpointOpenAIChat: true, config.EndpointOpenAIResponses: true} bedrock := config.ProviderInfo{ID: "bedrock", Models: []string{"claude"}, SupportedEndpoints: map[string]bool{config.EndpointBedrockConverse: true}} openai := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5", "gpt-5-mini"}, SupportedEndpoints: both} diff --git a/internal/clients/gemini/gemini_test.go b/internal/clients/gemini/gemini_test.go index 495bcb1..19c07d6 100644 --- a/internal/clients/gemini/gemini_test.go +++ b/internal/clients/gemini/gemini_test.go @@ -76,6 +76,8 @@ func TestValidateHost(t *testing.T) { func TestWriteConfig_Vertex(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) home, err := writeConfig("vertex-ai") @@ -105,7 +107,10 @@ func TestWriteConfig_Vertex(t *testing.T) { // The headless launch (ADR 0007): flags win, LastLaunch fills what they // leave empty, and anything left goes to the first compatible provider. func TestSpec(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) openai := config.ProviderInfo{ID: "openai", SupportedEndpoints: map[string]bool{config.EndpointOpenAIChat: true}} vertexOnly := config.ProviderInfo{ID: "vertex", SupportedEndpoints: map[string]bool{config.EndpointVertexGemini: true}} diff --git a/internal/clients/hermes/hermes_test.go b/internal/clients/hermes/hermes_test.go index 2b7efbc..f3cd97b 100644 --- a/internal/clients/hermes/hermes_test.go +++ b/internal/clients/hermes/hermes_test.go @@ -105,7 +105,10 @@ func TestInstallUninstall(t *testing.T) { } func TestSpec(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) chat := map[string]bool{config.EndpointOpenAIChat: true} openrouter := config.ProviderInfo{ID: "openrouter", Models: []string{"gpt-5", "gpt-5-mini"}, SupportedEndpoints: chat} responses := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5"}, SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}} diff --git a/internal/clients/opencode/opencode_test.go b/internal/clients/opencode/opencode_test.go index 1e67cf2..c3fd385 100644 --- a/internal/clients/opencode/opencode_test.go +++ b/internal/clients/opencode/opencode_test.go @@ -3,12 +3,9 @@ package opencode import ( "encoding/json" "os" - "os/exec" - "os/signal" "path/filepath" "slices" "strings" - "syscall" "testing" "github.com/tailscale/aperture-cli/internal/config" @@ -79,7 +76,10 @@ func TestPickSDK_ResponsesBeatsChat(t *testing.T) { func TestWriteProviderConfig(t *testing.T) { tmp := t.TempDir() + // HOME and XDG_CONFIG_HOME cover Unix; UserHomeDir on Windows reads + // USERPROFILE. t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) tests := []struct { @@ -227,7 +227,10 @@ func TestWriteProviderConfig(t *testing.T) { // provider, and OpenCode picks its own model unless --model says otherwise. func TestSpec(t *testing.T) { tmp := t.TempDir() + // HOME and XDG_CONFIG_HOME cover Unix; UserHomeDir on Windows reads + // USERPROFILE. t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) embed := config.ProviderInfo{ID: "embed", Models: []string{"embed-1"}, SupportedEndpoints: map[string]bool{"/v1/embeddings": true}} @@ -334,7 +337,10 @@ func TestSpec(t *testing.T) { // second write would replace the first, and the first child to exit would // delete it under the second. func TestWriteProviderConfigIsUniquePerLaunch(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + home := t.TempDir() + t.Setenv("HOME", home) + // UserHomeDir on Windows reads USERPROFILE, not HOME. + t.Setenv("USERPROFILE", home) provs := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5"}, SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}} first, cleanupFirst, err := writeProviderConfig(testHost, provs) @@ -355,45 +361,3 @@ func TestWriteProviderConfigIsUniquePerLaunch(t *testing.T) { t.Errorf("the second launch's config is gone: %v", err) } } - -// A write that fails partway through (disk full, quota) must not leave the -// partially written temp file behind in ~/.opencode. -func TestWriteProviderConfigRemovesTempFileOnWriteFailure(t *testing.T) { - if os.Getenv("TEST_OPENCODE_WRITE_FAILURE") == "1" { - signal.Ignore(syscall.SIGXFSZ) - defer signal.Reset(syscall.SIGXFSZ) - var orig syscall.Rlimit - if err := syscall.Getrlimit(syscall.RLIMIT_FSIZE, &orig); err != nil { - t.Fatal(err) - } - defer syscall.Setrlimit(syscall.RLIMIT_FSIZE, &orig) - limited := syscall.Rlimit{Cur: 1, Max: orig.Max} - if err := syscall.Setrlimit(syscall.RLIMIT_FSIZE, &limited); err != nil { - t.Fatal(err) - } - - provs := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5"}, SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}} - if _, _, err := writeProviderConfig(testHost, provs); err == nil { - t.Fatal("writeProviderConfig: want a write failure under the file-size limit") - } - return - } - - home := t.TempDir() - cmd := exec.Command(os.Args[0], "-test.run=^TestWriteProviderConfigRemovesTempFileOnWriteFailure$") - cmd.Env = append(os.Environ(), "TEST_OPENCODE_WRITE_FAILURE=1", "HOME="+home) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("child process failed: %v\n%s", err, out) - } - - entries, err := os.ReadDir(filepath.Join(home, ".opencode")) - if err != nil { - t.Fatal(err) - } - for _, e := range entries { - if strings.HasPrefix(e.Name(), "tmp_aperture_config_") { - t.Errorf("leaked temp file %s after a write failure", e.Name()) - } - } -} diff --git a/internal/clients/opencode/opencode_write_failure_unix_test.go b/internal/clients/opencode/opencode_write_failure_unix_test.go new file mode 100644 index 0000000..193c864 --- /dev/null +++ b/internal/clients/opencode/opencode_write_failure_unix_test.go @@ -0,0 +1,58 @@ +//go:build unix + +package opencode + +import ( + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +// A write that fails partway through (disk full, quota) must not leave the +// partially written temp file behind in ~/.opencode. Unix-only: the failure +// is induced with RLIMIT_FSIZE and SIGXFSZ, which have no Windows equivalent. +func TestWriteProviderConfigRemovesTempFileOnWriteFailure(t *testing.T) { + if os.Getenv("TEST_OPENCODE_WRITE_FAILURE") == "1" { + signal.Ignore(syscall.SIGXFSZ) + defer signal.Reset(syscall.SIGXFSZ) + var orig syscall.Rlimit + if err := syscall.Getrlimit(syscall.RLIMIT_FSIZE, &orig); err != nil { + t.Fatal(err) + } + defer syscall.Setrlimit(syscall.RLIMIT_FSIZE, &orig) + limited := syscall.Rlimit{Cur: 1, Max: orig.Max} + if err := syscall.Setrlimit(syscall.RLIMIT_FSIZE, &limited); err != nil { + t.Fatal(err) + } + + provs := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5"}, SupportedEndpoints: map[string]bool{config.EndpointOpenAIResponses: true}} + if _, _, err := writeProviderConfig(testHost, provs); err == nil { + t.Fatal("writeProviderConfig: want a write failure under the file-size limit") + } + return + } + + home := t.TempDir() + cmd := exec.Command(os.Args[0], "-test.run=^TestWriteProviderConfigRemovesTempFileOnWriteFailure$") + cmd.Env = append(os.Environ(), "TEST_OPENCODE_WRITE_FAILURE=1", "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("child process failed: %v\n%s", err, out) + } + + entries, err := os.ReadDir(filepath.Join(home, ".opencode")) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), "tmp_aperture_config_") { + t.Errorf("leaked temp file %s after a write failure", e.Name()) + } + } +} diff --git a/internal/clients/pilike/pilike_test.go b/internal/clients/pilike/pilike_test.go index 3ac3e47..99af30c 100644 --- a/internal/clients/pilike/pilike_test.go +++ b/internal/clients/pilike/pilike_test.go @@ -47,6 +47,8 @@ func isolateConfigDir(t *testing.T) { t.Helper() tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) } diff --git a/internal/config/runlog_test.go b/internal/config/runlog_test.go index f6956e0..121cbd7 100644 --- a/internal/config/runlog_test.go +++ b/internal/config/runlog_test.go @@ -16,6 +16,8 @@ import ( func TestOpenRunLogAppendsThenTruncates(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) write := func(s string) { @@ -62,6 +64,8 @@ func TestOpenRunLogAppendsThenTruncates(t *testing.T) { func TestStartRunLogAndRaiseRunLog(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) closeLog := config.StartRunLog() diff --git a/internal/config/startup_test.go b/internal/config/startup_test.go index bc4a248..5e40a2e 100644 --- a/internal/config/startup_test.go +++ b/internal/config/startup_test.go @@ -14,6 +14,8 @@ func loadInto(t *testing.T, s config.Settings) *config.Global { t.Helper() tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) if err := config.SaveSettings(s); err != nil { t.Fatalf("SaveSettings: %v", err) diff --git a/internal/config/state_test.go b/internal/config/state_test.go index 4edca73..d08ee4c 100644 --- a/internal/config/state_test.go +++ b/internal/config/state_test.go @@ -14,6 +14,8 @@ import ( func TestLaunchState_RoundTrip(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg == "" { t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) } @@ -43,6 +45,8 @@ func TestLaunchState_RoundTrip(t *testing.T) { func TestLaunchState_LegacyMigration(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) // Seed a launcher.json in the old shape that used lastProfileName. @@ -80,6 +84,8 @@ func TestLaunchState_LegacyMigration(t *testing.T) { func TestGlobal_RecordLaunchBindsActiveEndpoint(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) g := &config.Global{Settings: config.Settings{Endpoints: []config.EndpointEntry{ @@ -96,6 +102,8 @@ func TestGlobal_RecordLaunchBindsActiveEndpoint(t *testing.T) { func TestSettings_RoundTrip(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) want := config.Settings{ @@ -133,6 +141,8 @@ func TestSettings_RoundTrip(t *testing.T) { func TestSettings_MissingFileUsesDefaults(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) got, err := config.LoadSettings() @@ -147,6 +157,8 @@ func TestSettings_MissingFileUsesDefaults(t *testing.T) { func TestSettings_InvalidJSONReturnsErrorWithoutReplacingFile(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) // Ask for the config dir rather than assuming XDG layout: on darwin // os.UserConfigDir ignores XDG_CONFIG_HOME and resolves under HOME. @@ -179,6 +191,8 @@ func TestSettings_InvalidJSONReturnsErrorWithoutReplacingFile(t *testing.T) { func TestGlobal_SetApertureHost_RotatesToFront(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) g := &config.Global{ @@ -207,6 +221,8 @@ func TestGlobal_SetApertureHost_RotatesToFront(t *testing.T) { func TestGlobal_SetActiveEndpoint_DistinguishesBridge(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) g := &config.Global{ @@ -231,6 +247,8 @@ func TestGlobal_SetActiveEndpoint_DistinguishesBridge(t *testing.T) { func TestGlobal_SetActiveEndpoint_URLEditKeepsName(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) g := &config.Global{ @@ -255,6 +273,8 @@ func TestGlobal_SetActiveEndpoint_URLEditKeepsName(t *testing.T) { func TestGlobal_RemoveInactiveEndpointPreservesRuntimeHost(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) g := &config.Global{ @@ -275,6 +295,8 @@ func TestGlobal_RemoveInactiveEndpointPreservesRuntimeHost(t *testing.T) { func TestGlobal_ReplaceEndpointDeduplicates(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) active := config.Direct("http://active") @@ -291,6 +313,8 @@ func TestGlobal_ReplaceEndpointDeduplicates(t *testing.T) { func TestGlobal_SetBridgeTailnetPersists(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) g := &config.Global{Settings: config.Settings{ @@ -315,6 +339,8 @@ func TestGlobal_SetBridgeTailnetPersists(t *testing.T) { func TestBridgeStateDir(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) got, err := config.BridgeStateDir("bridge-abcdef", 1) @@ -342,6 +368,8 @@ func TestBridgeStateDir(t *testing.T) { func TestBridgeStateSlots(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) slots, err := config.BridgeStateSlots("bridge-abcdef") @@ -375,6 +403,8 @@ func TestBridgeStateSlots(t *testing.T) { func TestClientConfig_TypedStore(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) type myCfg struct { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 0e86fa9..402b617 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -301,6 +301,8 @@ func TestSettingsMenu_ToggleYolo(t *testing.T) { withFakeClients(t, nil) tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") g := &config.Global{} @@ -533,6 +535,8 @@ func TestSetupGuideMenu_BridgeDoesNotRequireSystemTailscale(t *testing.T) { func TestEndpointBridgeMenu_AddsFirstBridgeInline(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") m := &model{ g: &config.Global{Settings: config.Settings{ @@ -591,6 +595,8 @@ func TestEndpointBridgeMenu_AddsFirstBridgeInline(t *testing.T) { func TestEndpointBridgeMenu_ConnectsExistingBridgeWithoutPrompting(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} m := &model{ @@ -626,6 +632,8 @@ func TestEndpointBridgeMenu_ConnectsExistingBridgeWithoutPrompting(t *testing.T) func TestInitOpensOnTheStartEndpoint(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} saved := config.Direct("http://saved") @@ -654,6 +662,8 @@ func TestInitOpensOnTheStartEndpoint(t *testing.T) { func TestInitOpensOnTheSavedEndpointWhenNothingIsNamed(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") saved := config.Direct("http://saved") g := &config.Global{Settings: config.Settings{Endpoints: []config.EndpointEntry{{Endpoint: saved}}}} @@ -673,6 +683,8 @@ func TestInitOpensOnTheSavedEndpointWhenNothingIsNamed(t *testing.T) { func TestPreflightOverrideReplacesGuessedEndpoint(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} previous := config.Direct("http://other") @@ -714,6 +726,8 @@ func TestPreflightOverrideReplacesGuessedEndpoint(t *testing.T) { func TestPreflightOverrideRejectsBadURLWithoutStoppingTheAttempt(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} m := &model{ @@ -767,6 +781,8 @@ func TestTextFieldTakesTypedAndPastedTextOnly(t *testing.T) { func TestPreflightEscapeAbandonsDiscovery(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} previous := config.Direct("http://other") @@ -841,6 +857,8 @@ func TestSetupGuideEditPrefillsFailedURL(t *testing.T) { func TestEndpointsMenu_EditRetargetsWorkingEndpoint(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} connected := config.Bridged(config.DefaultLocation, bridge.ID) @@ -896,6 +914,8 @@ func pickerModel(t *testing.T) *model { t.Helper() tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") return &model{ g: &config.Global{ @@ -1146,6 +1166,8 @@ func TestEndpointBridgeMenu_OffersAddBridgeWhenOneExists(t *testing.T) { func TestBridgeEndpointFailureKeepsPreviousEndpointActive(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") old := config.Direct("http://old") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Second"} @@ -1206,6 +1228,8 @@ func TestBridgeEndpointFailureKeepsPreviousEndpointActive(t *testing.T) { func TestDirectEndpointIsPromotedOnlyAfterModelsSucceed(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + t.Setenv("APPDATA", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") srv := modelsServer(t) old := config.Direct("http://old") From 89556758fe2426a694db6a8534bcfb73e4553ffa Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 29 Oct 2026 17:10:29 +0000 Subject: [PATCH 2/5] release: sign Windows binaries with Azure Artifact Signing Windows zips shipped unsigned, so SmartScreen warned on every release (the hash changes each time, resetting reputation) and fleets that block unsigned executables could not run aperture at all. A second GoReleaser post-hook signs each aperture.exe with Jsign against Artifact Signing, before archiving, so zips and checksums describe signed bytes. Jsign runs on the existing macOS job; signtool would have forced a second job that re-zips and rewrites checksums after upload. Auth is GitHub OIDC through azure/login, so no signing secret is stored. Jsign is pinned by sha256 because it runs with the access token in its environment. A verify-windows job reads the published zips back on windows-latest and requires a Valid, timestamped signature from Tailscale Inc. Not exercised here: a real signature. That needs the Azure account, the Entra federated credential for the release environment and the repo vars/secrets listed in the README. See ADR 0009. --- .github/workflows/release.yaml | 58 ++++- .goreleaser.yaml | 7 +- README.md | 8 +- ...9-windows-signing-with-artifact-signing.md | 77 +++++++ scripts/setup-windows-signing.sh | 54 +++++ scripts/sign-windows.sh | 47 +++++ scripts/sign_authenticode_test.go | 199 ++++++++++++++++++ 7 files changed, 445 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0009-windows-signing-with-artifact-signing.md create mode 100755 scripts/setup-windows-signing.sh create mode 100755 scripts/sign-windows.sh create mode 100644 scripts/sign_authenticode_test.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 23d1e0a..23b8af1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,12 +14,21 @@ on: permissions: contents: write + # azure/login exchanges the run's OIDC token for an Azure access token. + id-token: write jobs: - # One macOS job builds everything: the linux binaries cross-compile with CGO off, and the darwin hooks sign and notarize before archiving. + # One macOS job builds everything: the linux binaries cross-compile with CGO off, and the darwin and windows hooks sign before archiving (darwin also notarizes). goreleaser: runs-on: macos-latest timeout-minutes: 60 + # The Entra federated credential trusts this environment's OIDC subject. + # A ref-based subject would need one credential per tag. + environment: release + env: + ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }} + ARTIFACT_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT }} + ARTIFACT_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -32,6 +41,22 @@ jobs: with: go-version-file: go.mod cache: false + - + name: Log in to Azure + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - + name: Set up Java + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + - + name: Set up Windows signing + run: bash scripts/setup-windows-signing.sh - name: Set up signing keychain and notary credentials env: @@ -64,3 +89,34 @@ jobs: name: Tear down signing keychain if: always() run: security delete-keychain "$SIGNING_KEYCHAIN" + + # Reads the published Windows zips back and asks Windows itself whether the + # signature is valid, timestamped and ours. The goreleaser job cannot: the + # trust store that matters is Windows'. + verify-windows: + needs: goreleaser + runs-on: windows-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Download Windows archives + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern '*_windows_*.zip' + - name: Verify Authenticode signatures + shell: pwsh + run: | + $zips = Get-ChildItem -Filter '*_windows_*.zip' + if ($zips.Count -ne 2) { throw "expected 2 Windows archives, found $($zips.Count)" } + foreach ($zip in $zips) { + $dir = Join-Path $env:RUNNER_TEMP $zip.BaseName + Expand-Archive -Path $zip.FullName -DestinationPath $dir + $sig = Get-AuthenticodeSignature (Join-Path $dir 'aperture.exe') + "$($zip.Name): $($sig.Status) $($sig.SignerCertificate.Subject)" + if ($sig.Status -ne 'Valid') { throw "$($zip.Name): signature is $($sig.Status)" } + if ($sig.SignerCertificate.Subject -notmatch 'O=Tailscale Inc\.') { throw "$($zip.Name): unexpected signer" } + if ($null -eq $sig.TimeStamperCertificate) { throw "$($zip.Name): no timestamp" } + } diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5a3429c..7ce25db 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -22,12 +22,15 @@ builds: goarch: - amd64 - arm64 - # Darwin releases require signatures and notarization. Only snapshots - # may skip these checks; Linux and Windows builds pass through unsigned. + # Darwin and Windows releases require signatures (and, on darwin, + # notarization). Only snapshots may skip these checks; Linux builds pass + # through unsigned. hooks: post: - cmd: 'bash scripts/sign-macos.sh "{{ .Path }}" "{{ .Target }}" "{{ .IsSnapshot }}"' output: true + - cmd: 'bash scripts/sign-windows.sh "{{ .Path }}" "{{ .Target }}" "{{ .IsSnapshot }}"' + output: true archives: - formats: diff --git a/README.md b/README.md index 324b8a8..8d5bef9 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ make build macOS builds are Developer ID signed and notarized. curl and `go install` never set the quarantine attribute, so Gatekeeper stays out of the way. If you download the archive in a browser and double-click the binary instead, macOS may block it anyway (common on managed Macs): open System Settings > Privacy & Security and click **Open Anyway** next to the blocked entry. -Windows builds ship as a zip containing `aperture.exe`; the install script is Unix-only, so on Windows download the archive from the release page or use `go install`. Windows builds are not code-signed yet, so SmartScreen may warn until the binary builds reputation. +Windows builds ship as a zip containing `aperture.exe`; the install script is Unix-only, so on Windows download the archive from the release page or use `go install`. Windows builds are Authenticode signed through Azure Artifact Signing (formerly Trusted Signing) with a timestamp, so the signature stays valid after the short-lived certificate expires. `go install` builds locally and is unsigned. ## Usage @@ -217,7 +217,11 @@ and testing an installed binary with `APERTURE_E2E_BIN`. ## Releasing -Push a tag. The release workflow runs GoReleaser on a macOS runner: it imports the Developer ID certificate from GitHub secrets into a temporary keychain, and a build hook signs and notarizes each darwin binary before archiving, so no unsigned darwin build is ever published. Linux and Windows binaries cross-compile from the same run and are published unsigned. A failed signature or a rejected notarization fails the run before upload. Required secrets: `APPLE_CERT_P12` (the base64-encoded .p12), `APPLE_CERT_PASSWORD`, `APPLE_ID` and `APPLE_ID_PASSWORD` (an app-specific password). +Push a tag. The release workflow runs GoReleaser on a macOS runner: it imports the Developer ID certificate from GitHub secrets into a temporary keychain, and a build hook signs and notarizes each darwin binary before archiving, so no unsigned darwin build is ever published. Windows binaries cross-compile from the same run and a second build hook Authenticode-signs each `aperture.exe` with [Jsign](https://ebourg.github.io/jsign/) against Azure Artifact Signing, authenticated by GitHub OIDC rather than a stored secret. Linux binaries are published unsigned. A failed signature or a rejected notarization fails the run before upload, and a `verify-windows` job then downloads the published zips and has Windows confirm each signature is valid, timestamped and issued to Tailscale Inc. ([ADR 0009](docs/adr/0009-windows-signing-with-artifact-signing.md)). + +Required for macOS: secrets `APPLE_CERT_P12` (the base64-encoded .p12), `APPLE_CERT_PASSWORD`, `APPLE_ID` and `APPLE_ID_PASSWORD` (an app-specific password). + +Required for Windows: secrets `AZURE_CLIENT_ID`, `AZURE_TENANT_ID` and `AZURE_SUBSCRIPTION_ID` of an Entra app with a federated credential for the `release` GitHub environment and the *Artifact Signing Certificate Profile Signer* role on the signing account; variables `ARTIFACT_SIGNING_ENDPOINT` (the account's regional host, e.g. `weu.codesigning.azure.net`), `ARTIFACT_SIGNING_ACCOUNT` and `ARTIFACT_SIGNING_PROFILE`. Local fallback on a Mac that has the certificate and a stored notary profile, for when the workflow could not sign: diff --git a/docs/adr/0009-windows-signing-with-artifact-signing.md b/docs/adr/0009-windows-signing-with-artifact-signing.md new file mode 100644 index 0000000..3f79b4a --- /dev/null +++ b/docs/adr/0009-windows-signing-with-artifact-signing.md @@ -0,0 +1,77 @@ +# 0009. Sign Windows binaries with Azure Artifact Signing from the macOS release job + +Status: proposed +Date: 2026-10-29 + +## Why? + +Windows builds shipped unsigned. SmartScreen warns on an unsigned +`aperture.exe` until it earns reputation, and every release resets that +reputation because the hash changes. Managed Windows fleets that block +unsigned executables cannot run it at all, which is the same team-use blocker +ADR 0008 names for the unsigned sidecar. + +Release already runs GoReleaser on one macOS runner, and `signtool.exe` does +not run there. + +## Decision + +1. Windows executables are Authenticode-signed with Azure Artifact Signing + (formerly Trusted Signing). Certificates are issued to the validated + Tailscale Inc. identity and live three days, so every signature carries an + RFC 3161 timestamp from `timestamp.acs.microsoft.com`. +2. Signing is a second GoReleaser build post-hook, `scripts/sign-windows.sh`, + beside the macOS one. It runs before archiving, so zips and + `checksums.txt` describe signed bytes. Only snapshots may skip it, and a + non-snapshot Windows build without readiness fails the release. +3. The signer is Jsign 7.6, pinned by sha256 in `setup-windows-signing.sh`. It + runs on the existing macOS runner; no Windows build job exists. +4. No long-lived Windows signing secret exists. `azure/login` trades the + run's OIDC token for an access token that `setup-windows-signing.sh` passes + to Jsign by environment variable name, never on the command line. +5. The release job runs in the GitHub environment `release`, and the Entra + federated credential trusts that environment's subject. +6. A `verify-windows` job downloads the published zips on `windows-latest` + and fails unless `Get-AuthenticodeSignature` reports `Valid`, a timestamp + and a signer whose subject contains `O=Tailscale Inc.`. + +## Consequences + +The release job now needs Java and `id-token: write`. A failure of +Azure, the signing quota or the timestamp server fails the whole release, +macOS included, because the hooks share one run. A re-run after the transient +fixes it; an Artifact Signing outage blocks releases until it clears. + +`verify-windows` runs after publication. A bad signature is caught after the +zips are public, not before. The gate that keeps unsigned bytes out of the +release is the hook; the job proves the hook's output is what Windows trusts. + +Jsign makes one extra signing API call per invocation, and the hook runs once +per binary, so a release spends four calls of quota. Linux builds stay +unsigned. The cask installs them without a signature check, as before. + +The signer subject check names the organisation, not the certificate +profile. A second Tailscale profile would also pass. + +## Rejected + +- **A Windows runner running signtool and the official action.** Needs a + second job that downloads the zips, signs, re-zips and rewrites + `checksums.txt` and anything derived from it, after GoReleaser already + uploaded them. Costs a window where unsigned zips are public. +- **An EV or OV certificate from a CA.** An HSM-bound key in CI means Azure + Key Vault plus a separate tool, and an OV certificate still builds + SmartScreen reputation slowly. +- **A service principal client secret.** Expires, must be rotated by hand and + is a standing credential on a repo that publishes to a Homebrew tap. OIDC + has nothing to leak. +- **A ref-based federated credential.** Tags differ per release; Entra does + not wildcard the subject without flexible credentials. +- **Leaving Windows unsigned.** See Why. + +## Revisit when + +Microsoft changes Artifact Signing eligibility or pricing for Tailscale, the +Linux cask path needs signature checks, a Windows-only build step (an MSI or +an installer) appears, or `verify-windows` flags a bad release often enough +that the check belongs before upload. diff --git a/scripts/setup-windows-signing.sh b/scripts/setup-windows-signing.sh new file mode 100755 index 0000000..104d96e --- /dev/null +++ b/scripts/setup-windows-signing.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# setup-windows-signing.sh prepares a GitHub runner for the goreleaser signing +# hook (scripts/sign-windows.sh): it downloads a pinned Jsign, which signs +# Windows executables from any OS, and exchanges the runner's Azure login for +# a short-lived Artifact Signing access token. Run it after azure/login; no +# long-lived Windows signing secret exists anywhere. +# +# Required environment: +# ARTIFACT_SIGNING_ENDPOINT regional endpoint host, e.g. weu.codesigning.azure.net +# ARTIFACT_SIGNING_ACCOUNT Artifact Signing account name +# ARTIFACT_SIGNING_PROFILE certificate profile name +# Required tools: az (logged in), curl, shasum. +set -euo pipefail + +: "${ARTIFACT_SIGNING_ENDPOINT:?set to the Artifact Signing endpoint host}" +: "${ARTIFACT_SIGNING_ACCOUNT:?set to the Artifact Signing account name}" +: "${ARTIFACT_SIGNING_PROFILE:?set to the certificate profile name}" + +JSIGN_VERSION=7.6 +JSIGN_SHA256=6cf58aa080e7ff82656f320cff8d4a3bff30408df8766806dcba3740dd839fa5 + +umask 077 +workdir=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/aperture-signing.XXXXXX") +jar="$workdir/jsign-$JSIGN_VERSION.jar" + +# The jar runs with the signing token in its environment, so it is verified +# against a pinned digest rather than trusted because the release URL resolved. +curl --fail --silent --show-error --location --retry 3 -o "$jar" \ + "https://github.com/ebourg/jsign/releases/download/$JSIGN_VERSION/jsign-$JSIGN_VERSION.jar" +if ! printf '%s %s\n' "$JSIGN_SHA256" "$jar" | shasum -a 256 -c - >/dev/null; then + rm -f "$jar" + echo "jsign $JSIGN_VERSION does not match the pinned sha256" >&2 + exit 1 +fi + +token=$(az account get-access-token --resource https://codesigning.azure.net \ + --query accessToken --output tsv) +if [ -z "$token" ]; then + echo "az returned no Artifact Signing access token" >&2 + exit 1 +fi +# Masked before it can reach any log line, including the GITHUB_ENV write. +echo "::add-mask::$token" + +echo "Signing endpoint: $ARTIFACT_SIGNING_ENDPOINT" +echo "Signing profile: $ARTIFACT_SIGNING_ACCOUNT/$ARTIFACT_SIGNING_PROFILE" +if [ -n "${GITHUB_ENV:-}" ]; then + # Windows releases require readiness; local snapshots may remain unsigned. + { + echo "APERTURE_WINDOWS_SIGNING_READY=1" + echo "JSIGN_JAR=$jar" + echo "ARTIFACT_SIGNING_TOKEN=$token" + } >> "$GITHUB_ENV" +fi diff --git a/scripts/sign-windows.sh b/scripts/sign-windows.sh new file mode 100755 index 0000000..59d2844 --- /dev/null +++ b/scripts/sign-windows.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# sign-windows.sh Authenticode-signs one freshly built goreleaser binary with +# Azure Artifact Signing; it runs as a build post-hook with the binary path, +# build target and snapshot flag. Non-Windows builds and explicit snapshots +# pass through unsigned. Windows releases require the setup script to have run +# and a signature that Artifact Signing issued and timestamped. A failure stops +# the release before GoReleaser archives or publishes it. +set -euo pipefail + +binary=$1 +target=$2 + +case "$target" in + windows_*) ;; + *) exit 0 ;; +esac + +# GoReleaser snapshots cannot publish, so only they may skip Windows signing. +if [ "${3:-false}" = "true" ]; then + exit 0 +fi + +# Setup sets this after it fetched Jsign and the access token. +if [ "${APERTURE_WINDOWS_SIGNING_READY:-}" != "1" ]; then + echo "signing not prepared for $target release" >&2 + exit 1 +fi + +endpoint="${ARTIFACT_SIGNING_ENDPOINT:?release job must set the signing endpoint}" +account="${ARTIFACT_SIGNING_ACCOUNT:?release job must set the signing account}" +profile="${ARTIFACT_SIGNING_PROFILE:?release job must set the certificate profile}" +jar="${JSIGN_JAR:?setup must export the Jsign jar}" +: "${ARTIFACT_SIGNING_TOKEN:?setup must export the access token}" + +# The token goes by environment variable name, never on the command line where +# ps shows it. Artifact Signing certificates live three days, so the RFC 3161 +# timestamp is what keeps the signature valid after they expire. +java -jar "$jar" \ + --storetype TRUSTEDSIGNING \ + --keystore "$endpoint" \ + --alias "$account/$profile" \ + --storepass env:ARTIFACT_SIGNING_TOKEN \ + --alg SHA-256 \ + --tsmode RFC3161 \ + --tsaurl http://timestamp.acs.microsoft.com \ + --name aperture \ + "$binary" diff --git a/scripts/sign_authenticode_test.go b/scripts/sign_authenticode_test.go new file mode 100644 index 0000000..6cd44a1 --- /dev/null +++ b/scripts/sign_authenticode_test.go @@ -0,0 +1,199 @@ +package scripts + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// fakeJava stands in for the JVM that runs Jsign. It records its arguments +// and, like Jsign, appends a signature to the file it was asked to sign. +const fakeJava = `#!/usr/bin/env bash +printf '%s\n' "$*" >> "$TEST_LOG" +[[ "$TEST_FAILURE" != sign ]] || exit 1 +printf 'signed\n' >> "${@: -1}" +` + +// Exercise the release hook with a fake JVM. Real signing needs an Azure +// login; these checks cover publication gating and that the token never +// appears on the command line. +func TestSignWindows(t *testing.T) { + // Go's test cache must track the script, which is otherwise read by Bash. + if _, err := os.ReadFile("sign-windows.sh"); err != nil { + t.Fatal(err) + } + type testCase struct { + name string + target string + ready bool + snapshot string + failure string + omit string // environment variable setup would normally export + wantError bool + } + tests := []testCase{ + {name: "linux", target: "linux_amd64_v1", ready: true}, + {name: "darwin", target: "darwin_arm64_v8.0", ready: true}, + {name: "windows amd64", ready: true}, + {name: "windows arm64", target: "windows_arm64_v8.0", ready: true}, + {name: "windows explicit false snapshot", ready: true, snapshot: "false"}, + {name: "missing readiness omitted snapshot", wantError: true}, + {name: "missing readiness explicit false", snapshot: "false", wantError: true}, + {name: "snapshot true missing readiness", snapshot: "true"}, + {name: "snapshot true with readiness", ready: true, snapshot: "true"}, + {name: "missing token", ready: true, omit: "ARTIFACT_SIGNING_TOKEN", wantError: true}, + {name: "missing jar", ready: true, omit: "JSIGN_JAR", wantError: true}, + {name: "missing endpoint", ready: true, omit: "ARTIFACT_SIGNING_ENDPOINT", wantError: true}, + {name: "missing account", ready: true, omit: "ARTIFACT_SIGNING_ACCOUNT", wantError: true}, + {name: "missing profile", ready: true, omit: "ARTIFACT_SIGNING_PROFILE", wantError: true}, + {name: "signing failure", ready: true, failure: "sign", wantError: true}, + } + const token = "test-access-token" + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + binDir := filepath.Join(dir, "tools") + if err := os.Mkdir(binDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "java"), []byte(fakeJava), 0700); err != nil { + t.Fatal(err) + } + binary := filepath.Join(dir, "aperture.exe") + if err := os.WriteFile(binary, []byte("unsigned\n"), 0700); err != nil { + t.Fatal(err) + } + target := tt.target + if target == "" { + target = "windows_amd64_v1" + } + args := []string{"sign-windows.sh", binary, target} + if tt.snapshot != "" { + args = append(args, tt.snapshot) + } + cmd := exec.Command("bash", args...) + env := append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "TEST_LOG="+filepath.Join(dir, "commands"), + "TEST_FAILURE="+tt.failure, + ) + if tt.ready { + env = append(env, "APERTURE_WINDOWS_SIGNING_READY=1") + } + for _, kv := range []string{ + "ARTIFACT_SIGNING_ENDPOINT=weu.codesigning.azure.net", + "ARTIFACT_SIGNING_ACCOUNT=test-account", + "ARTIFACT_SIGNING_PROFILE=test-profile", + "ARTIFACT_SIGNING_TOKEN=" + token, + "JSIGN_JAR=" + filepath.Join(dir, "jsign.jar"), + } { + if tt.omit == "" || !strings.HasPrefix(kv, tt.omit+"=") { + env = append(env, kv) + } + } + cmd.Env = env + output, err := cmd.CombinedOutput() + if (err != nil) != tt.wantError { + t.Fatalf("hook error = %v, want error %v\n%s", err, tt.wantError, output) + } + log, err := os.ReadFile(filepath.Join(dir, "commands")) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + commands := string(log) + contents, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + + signs := strings.HasPrefix(target, "windows_") && tt.snapshot != "true" + if !signs { + if commands != "" { + t.Errorf("skipped run must not call the signer, got:\n%s", commands) + } + if string(contents) != "unsigned\n" { + t.Errorf("binary must pass through untouched: %q", contents) + } + return + } + if tt.wantError { + if tt.failure == "" && commands != "" { + t.Errorf("a release that is not ready must not reach the signer, got:\n%s", commands) + } + if string(contents) != "unsigned\n" { + t.Errorf("a failed run must not leave a signed binary behind: %q", contents) + } + return + } + if string(contents) != "unsigned\nsigned\n" { + t.Errorf("binary was not signed: %q", contents) + } + if strings.Contains(commands+string(output), token) { + t.Errorf("access token leaked into the command line or output:\n%s%s", commands, output) + } + for _, want := range []string{ + "--storetype TRUSTEDSIGNING", + "--keystore weu.codesigning.azure.net", + "--alias test-account/test-profile", + "--storepass env:ARTIFACT_SIGNING_TOKEN", + "--tsmode RFC3161", + binary, + } { + if !strings.Contains(commands, want) { + t.Errorf("signer invocation lacks %q:\n%s", want, commands) + } + } + }) + } +} + +// The signer runs with the access token in its environment, so setup must +// refuse a jar that is not the pinned build, and say so, before any token is +// requested or exported. +func TestSetupWindowsSigningRejectsUnpinnedJsign(t *testing.T) { + if _, err := os.ReadFile("setup-windows-signing.sh"); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + binDir := filepath.Join(dir, "tools") + if err := os.Mkdir(binDir, 0700); err != nil { + t.Fatal(err) + } + fakes := map[string]string{ + // Writes whatever the pinned digest does not match to the -o target. + "curl": "#!/usr/bin/env bash\nwhile [[ $# -gt 0 ]]; do [[ \"$1\" == -o ]] && printf tampered > \"$2\"; shift; done\n", + "az": "#!/usr/bin/env bash\necho az >> \"$TEST_LOG\"\necho secret-token\n", + } + for name, body := range fakes { + if err := os.WriteFile(filepath.Join(binDir, name), []byte(body), 0700); err != nil { + t.Fatal(err) + } + } + githubEnv := filepath.Join(dir, "github-env") + cmd := exec.Command("bash", "setup-windows-signing.sh") + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "TEST_LOG="+filepath.Join(dir, "commands"), + "RUNNER_TEMP="+dir, + "GITHUB_ENV="+githubEnv, + "ARTIFACT_SIGNING_ENDPOINT=weu.codesigning.azure.net", + "ARTIFACT_SIGNING_ACCOUNT=test-account", + "ARTIFACT_SIGNING_PROFILE=test-profile", + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("setup accepted a jar that does not match the pinned digest\n%s", output) + } + if !strings.Contains(string(output), "pinned sha256") { + t.Errorf("setup failed for another reason:\n%s", output) + } + if _, err := os.Stat(filepath.Join(dir, "commands")); !os.IsNotExist(err) { + t.Errorf("setup requested a token before verifying the jar: %v", err) + } + if exported, err := os.ReadFile(githubEnv); err == nil && len(exported) > 0 { + t.Errorf("a rejected setup must export nothing, got:\n%s", exported) + } +} From 54320df89373d2cafdd0089bba023d5f2ea55e23 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 29 Oct 2026 18:04:36 +0000 Subject: [PATCH 3/5] release: sign Windows with a Cloud HSM key through a store-agnostic Jsign hook The Windows client team does not want signing key material in Actions, and the HSM route Nick pointed at keeps the key in Google Cloud. The previous commit hard-wired Azure Artifact Signing into the hook, so moving to the HSM would have meant rewriting the script, not changing a setting. sign-windows.sh now reads its key store from WINDOWS_SIGN_* variables and knows nothing about any provider. The release sets GOOGLECLOUD with a workload-identity access token; a new ci-windows-signing workflow runs the same hook with PKCS12 and a throwaway self-signed EC P-256 key, builds both architectures, trusts the certificate on a windows-latest runner and has Windows verify the result. That proves the Jsign, timestamp and PE paths, including ECDSA and arm64, before the HSM key or its CA certificate exist. verify-windows now shares verify-windows-signature.ps1 with it. Not exercised: a real HSM signature, workload identity federation, Windows' verdict on the self-signed build (no runner here), or trust in the CA chain. The release fails closed until GCP_* variables and scripts/windows-codesign-chain.pem exist, so do not merge before the key does. See ADR 0009, rewritten to this decision. --- .github/workflows/ci-windows-signing.yaml | 79 ++++++++++ .github/workflows/release.yaml | 42 +++--- README.md | 16 +- ...09-windows-signing-with-a-cloud-hsm-key.md | 92 ++++++++++++ ...9-windows-signing-with-artifact-signing.md | 77 ---------- scripts/make-selfsigned-codesign.sh | 52 +++++++ scripts/setup-windows-signing.sh | 45 ++---- scripts/sign-windows.sh | 55 ++++--- scripts/sign_authenticode_test.go | 137 +++++++++++++----- scripts/verify-windows-signature.ps1 | 21 +++ 10 files changed, 434 insertions(+), 182 deletions(-) create mode 100644 .github/workflows/ci-windows-signing.yaml create mode 100644 docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md delete mode 100644 docs/adr/0009-windows-signing-with-artifact-signing.md create mode 100755 scripts/make-selfsigned-codesign.sh create mode 100644 scripts/verify-windows-signature.ps1 diff --git a/.github/workflows/ci-windows-signing.yaml b/.github/workflows/ci-windows-signing.yaml new file mode 100644 index 0000000..07ca0f9 --- /dev/null +++ b/.github/workflows/ci-windows-signing.yaml @@ -0,0 +1,79 @@ +name: Windows signing + +# Proves the release signing path end to end with a throwaway self-signed key: +# the same setup and hook scripts the release runs, then Windows' own verdict +# on the result. What it cannot prove is trust in the production CA; the +# release's verify-windows job does that. +on: + push: + branches: ["main"] + paths: + - ".github/workflows/ci-windows-signing.yaml" + - ".goreleaser.yaml" + - "scripts/*windows*" + - "scripts/*selfsigned*" + + pull_request: + branches: ["main"] + paths: + - ".github/workflows/ci-windows-signing.yaml" + - ".goreleaser.yaml" + - "scripts/*windows*" + - "scripts/*selfsigned*" + + # Allows manual triggering of the workflow + workflow_dispatch: + +jobs: + sign-and-verify: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + + - name: Set up Java + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + + # Exports JSIGN_JAR and the readiness flag to the steps below, exactly + # as it does in the release. + - name: Set up Windows signing + shell: bash + run: bash scripts/setup-windows-signing.sh + + # Both architectures, because the release ships both and the arm64 PE + # is the one nobody on this runner can run. + - name: Build and sign with a self-signed key + shell: bash + env: + CGO_ENABLED: "0" + run: | + set -euo pipefail + work="${RUNNER_TEMP//\\//}/signing-smoke" + password=$(openssl rand -hex 16) + echo "::add-mask::$password" + CODESIGN_P12_PASSWORD="$password" bash scripts/make-selfsigned-codesign.sh "$work" + export WINDOWS_SIGN_STORETYPE=PKCS12 + export WINDOWS_SIGN_KEYSTORE="$work/codesign.p12" + export WINDOWS_SIGN_STOREPASS="$password" + for arch in amd64 arm64; do + GOOS=windows GOARCH=$arch go build -ldflags '-s -w' -o "$work/aperture_$arch.exe" ./cmd/aperture + bash scripts/sign-windows.sh "$work/aperture_$arch.exe" "windows_${arch}_v1" false + done + echo "WORK=$work" >> "$GITHUB_ENV" + + # The runner is an administrator, so the throwaway certificate can be + # trusted for the length of the job and Windows can answer Valid. + - name: Verify with Windows + shell: pwsh + run: | + Import-Certificate -FilePath "$env:WORK/codesign.crt" -CertStoreLocation Cert:\LocalMachine\Root | Out-Null + ./scripts/verify-windows-signature.ps1 -SignerPattern 'CN=Aperture Test Signing' -Path @( + "$env:WORK/aperture_amd64.exe", "$env:WORK/aperture_arm64.exe") diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 23b8af1..dd7f11e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,7 +14,7 @@ on: permissions: contents: write - # azure/login exchanges the run's OIDC token for an Azure access token. + # google-github-actions/auth exchanges the run's OIDC token for a Google access token. id-token: write jobs: @@ -22,13 +22,9 @@ jobs: goreleaser: runs-on: macos-latest timeout-minutes: 60 - # The Entra federated credential trusts this environment's OIDC subject. - # A ref-based subject would need one credential per tag. + # The workload identity provider's attribute condition trusts this + # environment. A ref-based condition would match a different tag each run. environment: release - env: - ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }} - ARTIFACT_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT }} - ARTIFACT_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -42,12 +38,13 @@ jobs: go-version-file: go.mod cache: false - - name: Log in to Azure - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + name: Log in to Google Cloud + id: gcp + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + token_format: access_token - name: Set up Java uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 @@ -85,6 +82,14 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN is scoped to this repo only; the tap needs the App token. HOMEBREW_TAP_TOKEN: ${{ steps.tap-token.outputs.token }} + # scripts/sign-windows.sh reads the Windows key store from here. The + # ci-windows-signing workflow runs the same hook with PKCS12 and a + # throwaway key; this is the line that differs. + WINDOWS_SIGN_STORETYPE: GOOGLECLOUD + WINDOWS_SIGN_KEYSTORE: ${{ vars.GCP_KMS_KEYRING }} + WINDOWS_SIGN_ALIAS: ${{ vars.GCP_KMS_KEY }} + WINDOWS_SIGN_CERTFILE: scripts/windows-codesign-chain.pem + WINDOWS_SIGN_STOREPASS: ${{ steps.gcp.outputs.access_token }} - name: Tear down signing keychain if: always() @@ -100,6 +105,10 @@ jobs: permissions: contents: read steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.inputs.tag || github.ref }} - name: Download Windows archives shell: bash env: @@ -111,12 +120,9 @@ jobs: run: | $zips = Get-ChildItem -Filter '*_windows_*.zip' if ($zips.Count -ne 2) { throw "expected 2 Windows archives, found $($zips.Count)" } - foreach ($zip in $zips) { + $exes = foreach ($zip in $zips) { $dir = Join-Path $env:RUNNER_TEMP $zip.BaseName Expand-Archive -Path $zip.FullName -DestinationPath $dir - $sig = Get-AuthenticodeSignature (Join-Path $dir 'aperture.exe') - "$($zip.Name): $($sig.Status) $($sig.SignerCertificate.Subject)" - if ($sig.Status -ne 'Valid') { throw "$($zip.Name): signature is $($sig.Status)" } - if ($sig.SignerCertificate.Subject -notmatch 'O=Tailscale Inc\.') { throw "$($zip.Name): unexpected signer" } - if ($null -eq $sig.TimeStamperCertificate) { throw "$($zip.Name): no timestamp" } + Join-Path $dir 'aperture.exe' } + ./scripts/verify-windows-signature.ps1 -SignerPattern 'O=Tailscale Inc\.' -Path $exes diff --git a/README.md b/README.md index 8d5bef9..aef059a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ make build macOS builds are Developer ID signed and notarized. curl and `go install` never set the quarantine attribute, so Gatekeeper stays out of the way. If you download the archive in a browser and double-click the binary instead, macOS may block it anyway (common on managed Macs): open System Settings > Privacy & Security and click **Open Anyway** next to the blocked entry. -Windows builds ship as a zip containing `aperture.exe`; the install script is Unix-only, so on Windows download the archive from the release page or use `go install`. Windows builds are Authenticode signed through Azure Artifact Signing (formerly Trusted Signing) with a timestamp, so the signature stays valid after the short-lived certificate expires. `go install` builds locally and is unsigned. +Windows builds ship as a zip containing `aperture.exe`; the install script is Unix-only, so on Windows download the archive from the release page or use `go install`. Windows builds are Authenticode signed with a key held in a Google Cloud HSM, and timestamped so the signature stays valid after the certificate expires. `go install` builds locally and is unsigned. ## Usage @@ -217,11 +217,21 @@ and testing an installed binary with `APERTURE_E2E_BIN`. ## Releasing -Push a tag. The release workflow runs GoReleaser on a macOS runner: it imports the Developer ID certificate from GitHub secrets into a temporary keychain, and a build hook signs and notarizes each darwin binary before archiving, so no unsigned darwin build is ever published. Windows binaries cross-compile from the same run and a second build hook Authenticode-signs each `aperture.exe` with [Jsign](https://ebourg.github.io/jsign/) against Azure Artifact Signing, authenticated by GitHub OIDC rather than a stored secret. Linux binaries are published unsigned. A failed signature or a rejected notarization fails the run before upload, and a `verify-windows` job then downloads the published zips and has Windows confirm each signature is valid, timestamped and issued to Tailscale Inc. ([ADR 0009](docs/adr/0009-windows-signing-with-artifact-signing.md)). +Push a tag. The release workflow runs GoReleaser on a macOS runner: it imports the Developer ID certificate from GitHub secrets into a temporary keychain, and a build hook signs and notarizes each darwin binary before archiving, so no unsigned darwin build is ever published. Windows binaries cross-compile from the same run and a second build hook Authenticode-signs each `aperture.exe` with [Jsign](https://ebourg.github.io/jsign/), using a key that stays in a Google Cloud HSM. GitHub OIDC and workload identity federation stand in for a stored credential. Linux binaries are published unsigned. A failed signature or a rejected notarization fails the run before upload, and a `verify-windows` job then downloads the published zips and has Windows confirm each signature is valid, timestamped and issued to Tailscale Inc. ([ADR 0009](docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md)). Required for macOS: secrets `APPLE_CERT_P12` (the base64-encoded .p12), `APPLE_CERT_PASSWORD`, `APPLE_ID` and `APPLE_ID_PASSWORD` (an app-specific password). -Required for Windows: secrets `AZURE_CLIENT_ID`, `AZURE_TENANT_ID` and `AZURE_SUBSCRIPTION_ID` of an Entra app with a federated credential for the `release` GitHub environment and the *Artifact Signing Certificate Profile Signer* role on the signing account; variables `ARTIFACT_SIGNING_ENDPOINT` (the account's regional host, e.g. `weu.codesigning.azure.net`), `ARTIFACT_SIGNING_ACCOUNT` and `ARTIFACT_SIGNING_PROFILE`. +Required for Windows: variables `GCP_WIF_PROVIDER` and `GCP_SERVICE_ACCOUNT` (the service account holds *Cloud KMS CryptoKey Signer* on the signing key only, and the provider trusts only this repository's `release` environment), `GCP_KMS_KEYRING` (`projects/…/locations/…/keyRings/…`) and `GCP_KMS_KEY` (`/cryptoKeyVersions/N`), plus the CA's PEM chain committed as `scripts/windows-codesign-chain.pem`. The `release` GitHub environment is restricted to tags. + +The signing path is exercised without the HSM key by the Windows signing workflow, which builds both architectures, signs them with a throwaway self-signed key (`scripts/make-selfsigned-codesign.sh`) through the same hook with `WINDOWS_SIGN_STORETYPE=PKCS12`, and asks Windows to verify them. Run the same locally with Java 17+ and `openssl`: + +```sh +CODESIGN_P12_PASSWORD=x bash scripts/make-selfsigned-codesign.sh /tmp/id +bash scripts/setup-windows-signing.sh # then export JSIGN_JAR it prints +APERTURE_WINDOWS_SIGNING_READY=1 WINDOWS_SIGN_STORETYPE=PKCS12 \ + WINDOWS_SIGN_KEYSTORE=/tmp/id/codesign.p12 WINDOWS_SIGN_STOREPASS=x \ + bash scripts/sign-windows.sh aperture.exe windows_amd64_v1 false +``` Local fallback on a Mac that has the certificate and a stored notary profile, for when the workflow could not sign: diff --git a/docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md b/docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md new file mode 100644 index 0000000..fb3f045 --- /dev/null +++ b/docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md @@ -0,0 +1,92 @@ +# 0009. Sign Windows binaries with an aperture-only Cloud HSM key + +Status: proposed +Date: 2026-10-29 + +## Why? + +Windows builds shipped unsigned. SmartScreen warns on an unsigned +`aperture.exe`, and every release starts from zero reputation because the hash +changes. Managed Windows fleets that block unsigned executables cannot run it +at all, which is the same team-use blocker ADR 0008 names for the unsigned +sidecar. + +The Windows client team does not want signing key material in GitHub Actions, +and the client's own signing key is a production identity. Release already +runs GoReleaser on one macOS runner, and `signtool.exe` does not run there. + +## Decision + +1. Windows executables are Authenticode-signed with a key that never leaves a + Google Cloud HSM. It is an aperture-only key, in its own key ring, and is + never the client's `codesigning` key. A public CA issues the certificate + against it. +2. Signing is a GoReleaser build post-hook, `scripts/sign-windows.sh`, beside + the macOS one. It runs before archiving, so zips and `checksums.txt` + describe signed bytes. Only snapshots may skip it, and a non-snapshot + Windows build without readiness fails the release. +3. The signer is Jsign 7.6, pinned by sha256 in `setup-windows-signing.sh`. It + runs on the existing macOS runner, so no Windows job sits inside the + release. Every signature carries an RFC 3161 timestamp. +4. The key store is configuration. The hook reads `WINDOWS_SIGN_STORETYPE`, + `_KEYSTORE`, `_ALIAS`, `_CERTFILE` and `_STOREPASS`, and knows nothing + about Google. The release sets `GOOGLECLOUD`; the `ci-windows-signing` + workflow runs the same hook with `PKCS12` and a throwaway self-signed key + from `make-selfsigned-codesign.sh`, so the path is proved before the HSM + key exists. +5. The runner holds no long-lived credential. Workload identity federation + trades the run's OIDC token for an access token whose service account has + `roles/cloudkms.signer` on that one key. The provider trusts only this + repository's `release` environment. +6. `verify-windows` downloads the published zips on `windows-latest` and runs + `scripts/verify-windows-signature.ps1`: `Get-AuthenticodeSignature` must say + `Valid`, with a timestamp and a signer subject containing `O=Tailscale Inc.`. + The same script judges the self-signed CI build. + +## Consequences + +No key or certificate-with-key sits in Actions, but the runner can still ask +the HSM to sign while the job runs. The `release` environment restriction and +the signer role on a single key are the controls, and both are IAM, not code. +A compromised release job can sign aperture-shaped binaries as Tailscale Inc. +until its token expires. + +The release job now needs Java and `id-token: write`. A failure of Google +Cloud, the signing key or the timestamp server fails the whole release, macOS +included, because the hooks share one run. + +`verify-windows` runs after publication, so a bad signature is caught after +the zips are public. The hook is what keeps unsigned bytes out; the job proves +that what it produced is what Windows trusts. + +The CA certificate has to be bought, validated and renewed, unlike a +short-lived certificate from a managed service. Jsign supports only EC and RSA +PKCS#1 v1.5 keys, which rules out the RSA-PSS padding that blocked the +osslsigncode attempt. The CI smoke test uses an EC P-256 key, so a green run +shows Windows accepts that class of key; the CA must accept it too, or the key +is recreated as RSA. + +Linux builds stay unsigned. The cask installs them without a signature check, +as before. + +## Rejected + +- **Azure Artifact Signing.** Short-lived certificates and no purchase, with + OIDC auth from Actions. Rejected by the choice of existing infrastructure, + not on its merits; it is the fallback if the CA order stalls, and the hook's + storetype indirection means switching is configuration plus a different + login step. +- **SignTool with the Cloud KMS CNG provider.** Needs a Windows machine, which + puts a Windows job inside the release or signs after publication, leaving + unsigned zips public. It also gains nothing over Jsign on the same key. +- **The client's signing key or certificate in Actions.** The reason this + decision exists. +- **A self-signed certificate in releases.** Windows treats it as unsigned, + with a worse publisher prompt. + +## Revisit when + +The CA will not issue against an EC key, Google Cloud signing latency or +quota fails releases, the client team's builder can take over signing for +aperture, or a Windows-only artifact (an MSI) appears that needs a Windows +step in the release. diff --git a/docs/adr/0009-windows-signing-with-artifact-signing.md b/docs/adr/0009-windows-signing-with-artifact-signing.md deleted file mode 100644 index 3f79b4a..0000000 --- a/docs/adr/0009-windows-signing-with-artifact-signing.md +++ /dev/null @@ -1,77 +0,0 @@ -# 0009. Sign Windows binaries with Azure Artifact Signing from the macOS release job - -Status: proposed -Date: 2026-10-29 - -## Why? - -Windows builds shipped unsigned. SmartScreen warns on an unsigned -`aperture.exe` until it earns reputation, and every release resets that -reputation because the hash changes. Managed Windows fleets that block -unsigned executables cannot run it at all, which is the same team-use blocker -ADR 0008 names for the unsigned sidecar. - -Release already runs GoReleaser on one macOS runner, and `signtool.exe` does -not run there. - -## Decision - -1. Windows executables are Authenticode-signed with Azure Artifact Signing - (formerly Trusted Signing). Certificates are issued to the validated - Tailscale Inc. identity and live three days, so every signature carries an - RFC 3161 timestamp from `timestamp.acs.microsoft.com`. -2. Signing is a second GoReleaser build post-hook, `scripts/sign-windows.sh`, - beside the macOS one. It runs before archiving, so zips and - `checksums.txt` describe signed bytes. Only snapshots may skip it, and a - non-snapshot Windows build without readiness fails the release. -3. The signer is Jsign 7.6, pinned by sha256 in `setup-windows-signing.sh`. It - runs on the existing macOS runner; no Windows build job exists. -4. No long-lived Windows signing secret exists. `azure/login` trades the - run's OIDC token for an access token that `setup-windows-signing.sh` passes - to Jsign by environment variable name, never on the command line. -5. The release job runs in the GitHub environment `release`, and the Entra - federated credential trusts that environment's subject. -6. A `verify-windows` job downloads the published zips on `windows-latest` - and fails unless `Get-AuthenticodeSignature` reports `Valid`, a timestamp - and a signer whose subject contains `O=Tailscale Inc.`. - -## Consequences - -The release job now needs Java and `id-token: write`. A failure of -Azure, the signing quota or the timestamp server fails the whole release, -macOS included, because the hooks share one run. A re-run after the transient -fixes it; an Artifact Signing outage blocks releases until it clears. - -`verify-windows` runs after publication. A bad signature is caught after the -zips are public, not before. The gate that keeps unsigned bytes out of the -release is the hook; the job proves the hook's output is what Windows trusts. - -Jsign makes one extra signing API call per invocation, and the hook runs once -per binary, so a release spends four calls of quota. Linux builds stay -unsigned. The cask installs them without a signature check, as before. - -The signer subject check names the organisation, not the certificate -profile. A second Tailscale profile would also pass. - -## Rejected - -- **A Windows runner running signtool and the official action.** Needs a - second job that downloads the zips, signs, re-zips and rewrites - `checksums.txt` and anything derived from it, after GoReleaser already - uploaded them. Costs a window where unsigned zips are public. -- **An EV or OV certificate from a CA.** An HSM-bound key in CI means Azure - Key Vault plus a separate tool, and an OV certificate still builds - SmartScreen reputation slowly. -- **A service principal client secret.** Expires, must be rotated by hand and - is a standing credential on a repo that publishes to a Homebrew tap. OIDC - has nothing to leak. -- **A ref-based federated credential.** Tags differ per release; Entra does - not wildcard the subject without flexible credentials. -- **Leaving Windows unsigned.** See Why. - -## Revisit when - -Microsoft changes Artifact Signing eligibility or pricing for Tailscale, the -Linux cask path needs signature checks, a Windows-only build step (an MSI or -an installer) appears, or `verify-windows` flags a bad release often enough -that the check belongs before upload. diff --git a/scripts/make-selfsigned-codesign.sh b/scripts/make-selfsigned-codesign.sh new file mode 100755 index 0000000..8205ce0 --- /dev/null +++ b/scripts/make-selfsigned-codesign.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# make-selfsigned-codesign.sh [ec|rsa] writes a throwaway Authenticode +# code-signing identity: codesign.p12 (key and certificate, for Jsign's PKCS12 +# store) and codesign.crt (the public certificate, for a trust store). It +# exists to exercise the signing path before the HSM key and its CA +# certificate do; nothing signed with it is shippable. It never touches the +# release workflow. +# +# ec (the default) is a P-256 key, the class of key the Cloud HSM plan uses, +# so a green run also shows Jsign and Windows accept it. rsa is the fallback +# if the CA will not issue against an EC key. +# +# Required environment: +# CODESIGN_P12_PASSWORD password to protect codesign.p12 with +set -euo pipefail + +outdir=${1:?usage: make-selfsigned-codesign.sh [ec|rsa]} +alg=${2:-ec} +: "${CODESIGN_P12_PASSWORD:?set to the password for codesign.p12}" + +umask 077 +mkdir -p "$outdir" +key="$outdir/codesign.key" +config="$outdir/codesign.cnf" +trap 'rm -f "$key" "$config"' EXIT + +# A config file rather than -addext, which LibreSSL (macOS) lacks. +cat > "$config" <<'EOF' +[req] +distinguished_name = dn +x509_extensions = ext +prompt = no +[dn] +CN = Aperture Test Signing +O = Aperture Test (self-signed) +[ext] +basicConstraints = critical, CA:FALSE +keyUsage = critical, digitalSignature +extendedKeyUsage = critical, codeSigning +EOF + +case "$alg" in + ec) openssl ecparam -genkey -name prime256v1 -noout -out "$key" ;; + rsa) openssl genrsa -out "$key" 3072 2>/dev/null ;; + *) echo "unknown key algorithm $alg: want ec or rsa" >&2; exit 2 ;; +esac + +openssl req -new -x509 -days 7 -sha256 -config "$config" -key "$key" -out "$outdir/codesign.crt" +CODESIGN_P12_PASSWORD="$CODESIGN_P12_PASSWORD" openssl pkcs12 -export \ + -inkey "$key" -in "$outdir/codesign.crt" -name codesign \ + -passout env:CODESIGN_P12_PASSWORD -out "$outdir/codesign.p12" +echo "wrote $outdir/codesign.p12 and $outdir/codesign.crt ($alg, expires in 7 days)" diff --git a/scripts/setup-windows-signing.sh b/scripts/setup-windows-signing.sh index 104d96e..061fb31 100755 --- a/scripts/setup-windows-signing.sh +++ b/scripts/setup-windows-signing.sh @@ -1,54 +1,39 @@ #!/usr/bin/env bash -# setup-windows-signing.sh prepares a GitHub runner for the goreleaser signing -# hook (scripts/sign-windows.sh): it downloads a pinned Jsign, which signs -# Windows executables from any OS, and exchanges the runner's Azure login for -# a short-lived Artifact Signing access token. Run it after azure/login; no -# long-lived Windows signing secret exists anywhere. +# setup-windows-signing.sh prepares a runner for the goreleaser signing hook +# (scripts/sign-windows.sh): it downloads Jsign, which signs Windows +# executables from any OS, and checks it against a pinned digest. Credentials +# are not its business; the workflow hands the hook its key store settings. # -# Required environment: -# ARTIFACT_SIGNING_ENDPOINT regional endpoint host, e.g. weu.codesigning.azure.net -# ARTIFACT_SIGNING_ACCOUNT Artifact Signing account name -# ARTIFACT_SIGNING_PROFILE certificate profile name -# Required tools: az (logged in), curl, shasum. +# Required tools: curl, openssl. Java must be on PATH when the hook runs. set -euo pipefail -: "${ARTIFACT_SIGNING_ENDPOINT:?set to the Artifact Signing endpoint host}" -: "${ARTIFACT_SIGNING_ACCOUNT:?set to the Artifact Signing account name}" -: "${ARTIFACT_SIGNING_PROFILE:?set to the certificate profile name}" - JSIGN_VERSION=7.6 JSIGN_SHA256=6cf58aa080e7ff82656f320cff8d4a3bff30408df8766806dcba3740dd839fa5 +# Git Bash on Windows hands over RUNNER_TEMP with backslashes, which neither +# mktemp nor the JVM agree on once the path is spliced into a template. +base="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" umask 077 -workdir=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/aperture-signing.XXXXXX") +workdir=$(mktemp -d "${base//\\//}/aperture-signing.XXXXXX") jar="$workdir/jsign-$JSIGN_VERSION.jar" -# The jar runs with the signing token in its environment, so it is verified -# against a pinned digest rather than trusted because the release URL resolved. +# The jar runs with the signing credential in its environment, so it is +# verified against a pinned digest rather than trusted because the release URL +# resolved. openssl is the one digest tool macOS, Linux and Git Bash share. curl --fail --silent --show-error --location --retry 3 -o "$jar" \ "https://github.com/ebourg/jsign/releases/download/$JSIGN_VERSION/jsign-$JSIGN_VERSION.jar" -if ! printf '%s %s\n' "$JSIGN_SHA256" "$jar" | shasum -a 256 -c - >/dev/null; then +digest=$(openssl dgst -sha256 -r "$jar" | cut -d' ' -f1) +if [ "$digest" != "$JSIGN_SHA256" ]; then rm -f "$jar" echo "jsign $JSIGN_VERSION does not match the pinned sha256" >&2 exit 1 fi -token=$(az account get-access-token --resource https://codesigning.azure.net \ - --query accessToken --output tsv) -if [ -z "$token" ]; then - echo "az returned no Artifact Signing access token" >&2 - exit 1 -fi -# Masked before it can reach any log line, including the GITHUB_ENV write. -echo "::add-mask::$token" - -echo "Signing endpoint: $ARTIFACT_SIGNING_ENDPOINT" -echo "Signing profile: $ARTIFACT_SIGNING_ACCOUNT/$ARTIFACT_SIGNING_PROFILE" +echo "Jsign $JSIGN_VERSION: $jar" if [ -n "${GITHUB_ENV:-}" ]; then # Windows releases require readiness; local snapshots may remain unsigned. { echo "APERTURE_WINDOWS_SIGNING_READY=1" echo "JSIGN_JAR=$jar" - echo "ARTIFACT_SIGNING_TOKEN=$token" } >> "$GITHUB_ENV" fi diff --git a/scripts/sign-windows.sh b/scripts/sign-windows.sh index 59d2844..f4d336c 100755 --- a/scripts/sign-windows.sh +++ b/scripts/sign-windows.sh @@ -1,10 +1,26 @@ #!/usr/bin/env bash # sign-windows.sh Authenticode-signs one freshly built goreleaser binary with -# Azure Artifact Signing; it runs as a build post-hook with the binary path, -# build target and snapshot flag. Non-Windows builds and explicit snapshots -# pass through unsigned. Windows releases require the setup script to have run -# and a signature that Artifact Signing issued and timestamped. A failure stops -# the release before GoReleaser archives or publishes it. +# Jsign; it runs as a build post-hook with the binary path, build target and +# snapshot flag. Non-Windows builds and explicit snapshots pass through +# unsigned. Windows releases require the setup script to have run and a +# timestamped signature. A failure stops the release before GoReleaser +# archives or publishes it. +# +# The key store is configuration, not code, so swapping where the key lives +# touches only the environment, never this script: +# +# WINDOWS_SIGN_STORETYPE Jsign --storetype: PKCS12 (CI smoke test with a +# self-signed key), GOOGLECLOUD (release) +# WINDOWS_SIGN_KEYSTORE PKCS12: the .p12 path. GOOGLECLOUD: the key ring, +# projects/P/locations/L/keyRings/R +# WINDOWS_SIGN_STOREPASS PKCS12: the .p12 password. GOOGLECLOUD: an access +# token. Read from the environment by Jsign; it +# never appears on a command line. +# WINDOWS_SIGN_ALIAS optional. GOOGLECLOUD: /cryptoKeyVersions/N, +# pinned so signing needs no list permission. +# WINDOWS_SIGN_CERTFILE optional. GOOGLECLOUD: the CA's PEM chain, since +# the HSM holds only the private key. +# JSIGN_JAR set by scripts/setup-windows-signing.sh set -euo pipefail binary=$1 @@ -20,28 +36,29 @@ if [ "${3:-false}" = "true" ]; then exit 0 fi -# Setup sets this after it fetched Jsign and the access token. +# Setup sets this after it fetched and verified Jsign. if [ "${APERTURE_WINDOWS_SIGNING_READY:-}" != "1" ]; then echo "signing not prepared for $target release" >&2 exit 1 fi -endpoint="${ARTIFACT_SIGNING_ENDPOINT:?release job must set the signing endpoint}" -account="${ARTIFACT_SIGNING_ACCOUNT:?release job must set the signing account}" -profile="${ARTIFACT_SIGNING_PROFILE:?release job must set the certificate profile}" +storetype="${WINDOWS_SIGN_STORETYPE:?set the Jsign storetype}" +keystore="${WINDOWS_SIGN_KEYSTORE:?set the key store}" jar="${JSIGN_JAR:?setup must export the Jsign jar}" -: "${ARTIFACT_SIGNING_TOKEN:?setup must export the access token}" +: "${WINDOWS_SIGN_STOREPASS:?set the key store password or access token}" -# The token goes by environment variable name, never on the command line where -# ps shows it. Artifact Signing certificates live three days, so the RFC 3161 -# timestamp is what keeps the signature valid after they expire. -java -jar "$jar" \ - --storetype TRUSTEDSIGNING \ - --keystore "$endpoint" \ - --alias "$account/$profile" \ - --storepass env:ARTIFACT_SIGNING_TOKEN \ +args=(--storetype "$storetype" --keystore "$keystore" --storepass env:WINDOWS_SIGN_STOREPASS) +[ -z "${WINDOWS_SIGN_ALIAS:-}" ] || args+=(--alias "$WINDOWS_SIGN_ALIAS") +if [ -n "${WINDOWS_SIGN_CERTFILE:-}" ]; then + [ -f "$WINDOWS_SIGN_CERTFILE" ] || { echo "certificate chain $WINDOWS_SIGN_CERTFILE not found" >&2; exit 1; } + args+=(--certfile "$WINDOWS_SIGN_CERTFILE") +fi + +# A certificate expires; the RFC 3161 timestamp is what keeps the signature +# valid after it does, so a signature without one is a failure, not a default. +java -jar "$jar" "${args[@]}" \ --alg SHA-256 \ --tsmode RFC3161 \ - --tsaurl http://timestamp.acs.microsoft.com \ + --tsaurl http://timestamp.digicert.com \ --name aperture \ "$binary" diff --git a/scripts/sign_authenticode_test.go b/scripts/sign_authenticode_test.go index 6cd44a1..d1351c0 100644 --- a/scripts/sign_authenticode_test.go +++ b/scripts/sign_authenticode_test.go @@ -16,6 +16,20 @@ printf '%s\n' "$*" >> "$TEST_LOG" printf 'signed\n' >> "${@: -1}" ` +// cleanSigningEnv is the test process environment minus anything a developer +// shell or a CI job exports for the real signing hook, so a run is decided by +// the test's own settings. +func cleanSigningEnv() []string { + var env []string + for _, kv := range os.Environ() { + if strings.HasPrefix(kv, "WINDOWS_SIGN_") || strings.HasPrefix(kv, "JSIGN_") || strings.HasPrefix(kv, "APERTURE_WINDOWS_SIGNING_") { + continue + } + env = append(env, kv) + } + return env +} + // Exercise the release hook with a fake JVM. Real signing needs an Azure // login; these checks cover publication gating and that the token never // appears on the command line. @@ -30,7 +44,8 @@ func TestSignWindows(t *testing.T) { ready bool snapshot string failure string - omit string // environment variable setup would normally export + omit string // environment variable the workflow would normally set + certfile string // overrides WINDOWS_SIGN_CERTFILE, relative to the test dir wantError bool } tests := []testCase{ @@ -43,11 +58,12 @@ func TestSignWindows(t *testing.T) { {name: "missing readiness explicit false", snapshot: "false", wantError: true}, {name: "snapshot true missing readiness", snapshot: "true"}, {name: "snapshot true with readiness", ready: true, snapshot: "true"}, - {name: "missing token", ready: true, omit: "ARTIFACT_SIGNING_TOKEN", wantError: true}, + {name: "missing storepass", ready: true, omit: "WINDOWS_SIGN_STOREPASS", wantError: true}, {name: "missing jar", ready: true, omit: "JSIGN_JAR", wantError: true}, - {name: "missing endpoint", ready: true, omit: "ARTIFACT_SIGNING_ENDPOINT", wantError: true}, - {name: "missing account", ready: true, omit: "ARTIFACT_SIGNING_ACCOUNT", wantError: true}, - {name: "missing profile", ready: true, omit: "ARTIFACT_SIGNING_PROFILE", wantError: true}, + {name: "missing storetype", ready: true, omit: "WINDOWS_SIGN_STORETYPE", wantError: true}, + {name: "missing keystore", ready: true, omit: "WINDOWS_SIGN_KEYSTORE", wantError: true}, + {name: "optional alias omitted", ready: true, omit: "WINDOWS_SIGN_ALIAS"}, + {name: "certfile that does not exist", ready: true, certfile: "missing.pem", wantError: true}, {name: "signing failure", ready: true, failure: "sign", wantError: true}, } const token = "test-access-token" @@ -75,7 +91,7 @@ func TestSignWindows(t *testing.T) { args = append(args, tt.snapshot) } cmd := exec.Command("bash", args...) - env := append(os.Environ(), + env := append(cleanSigningEnv(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), "TEST_LOG="+filepath.Join(dir, "commands"), "TEST_FAILURE="+tt.failure, @@ -83,11 +99,19 @@ func TestSignWindows(t *testing.T) { if tt.ready { env = append(env, "APERTURE_WINDOWS_SIGNING_READY=1") } + certfile := filepath.Join(dir, "chain.pem") + if err := os.WriteFile(certfile, []byte("chain\n"), 0600); err != nil { + t.Fatal(err) + } + if tt.certfile != "" { + certfile = filepath.Join(dir, tt.certfile) + } for _, kv := range []string{ - "ARTIFACT_SIGNING_ENDPOINT=weu.codesigning.azure.net", - "ARTIFACT_SIGNING_ACCOUNT=test-account", - "ARTIFACT_SIGNING_PROFILE=test-profile", - "ARTIFACT_SIGNING_TOKEN=" + token, + "WINDOWS_SIGN_STORETYPE=GOOGLECLOUD", + "WINDOWS_SIGN_KEYSTORE=projects/p/locations/l/keyRings/r", + "WINDOWS_SIGN_ALIAS=key/cryptoKeyVersions/1", + "WINDOWS_SIGN_CERTFILE=" + certfile, + "WINDOWS_SIGN_STOREPASS=" + token, "JSIGN_JAR=" + filepath.Join(dir, "jsign.jar"), } { if tt.omit == "" || !strings.HasPrefix(kv, tt.omit+"=") { @@ -134,14 +158,22 @@ func TestSignWindows(t *testing.T) { if strings.Contains(commands+string(output), token) { t.Errorf("access token leaked into the command line or output:\n%s%s", commands, output) } - for _, want := range []string{ - "--storetype TRUSTEDSIGNING", - "--keystore weu.codesigning.azure.net", - "--alias test-account/test-profile", - "--storepass env:ARTIFACT_SIGNING_TOKEN", + wants := []string{ + "--storetype GOOGLECLOUD", + "--keystore projects/p/locations/l/keyRings/r", + "--alias key/cryptoKeyVersions/1", + "--certfile " + filepath.Join(dir, "chain.pem"), + "--storepass env:WINDOWS_SIGN_STOREPASS", "--tsmode RFC3161", binary, - } { + } + if tt.omit == "WINDOWS_SIGN_ALIAS" { + wants = wants[:2] + if strings.Contains(commands, "--alias") { + t.Errorf("an unset alias must not be passed to the signer:\n%s", commands) + } + } + for _, want := range wants { if !strings.Contains(commands, want) { t.Errorf("signer invocation lacks %q:\n%s", want, commands) } @@ -150,9 +182,9 @@ func TestSignWindows(t *testing.T) { } } -// The signer runs with the access token in its environment, so setup must -// refuse a jar that is not the pinned build, and say so, before any token is -// requested or exported. +// The signer runs with the signing credential in its environment, so setup +// must refuse a jar that is not the pinned build, and say so, before it +// exports readiness. func TestSetupWindowsSigningRejectsUnpinnedJsign(t *testing.T) { if _, err := os.ReadFile("setup-windows-signing.sh"); err != nil { t.Fatal(err) @@ -162,26 +194,17 @@ func TestSetupWindowsSigningRejectsUnpinnedJsign(t *testing.T) { if err := os.Mkdir(binDir, 0700); err != nil { t.Fatal(err) } - fakes := map[string]string{ - // Writes whatever the pinned digest does not match to the -o target. - "curl": "#!/usr/bin/env bash\nwhile [[ $# -gt 0 ]]; do [[ \"$1\" == -o ]] && printf tampered > \"$2\"; shift; done\n", - "az": "#!/usr/bin/env bash\necho az >> \"$TEST_LOG\"\necho secret-token\n", - } - for name, body := range fakes { - if err := os.WriteFile(filepath.Join(binDir, name), []byte(body), 0700); err != nil { - t.Fatal(err) - } + // Writes whatever the pinned digest does not match to the -o target. + curl := "#!/usr/bin/env bash\nwhile [[ $# -gt 0 ]]; do [[ \"$1\" == -o ]] && printf tampered > \"$2\"; shift; done\n" + if err := os.WriteFile(filepath.Join(binDir, "curl"), []byte(curl), 0700); err != nil { + t.Fatal(err) } githubEnv := filepath.Join(dir, "github-env") cmd := exec.Command("bash", "setup-windows-signing.sh") cmd.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), - "TEST_LOG="+filepath.Join(dir, "commands"), "RUNNER_TEMP="+dir, "GITHUB_ENV="+githubEnv, - "ARTIFACT_SIGNING_ENDPOINT=weu.codesigning.azure.net", - "ARTIFACT_SIGNING_ACCOUNT=test-account", - "ARTIFACT_SIGNING_PROFILE=test-profile", ) output, err := cmd.CombinedOutput() if err == nil { @@ -190,10 +213,54 @@ func TestSetupWindowsSigningRejectsUnpinnedJsign(t *testing.T) { if !strings.Contains(string(output), "pinned sha256") { t.Errorf("setup failed for another reason:\n%s", output) } - if _, err := os.Stat(filepath.Join(dir, "commands")); !os.IsNotExist(err) { - t.Errorf("setup requested a token before verifying the jar: %v", err) - } if exported, err := os.ReadFile(githubEnv); err == nil && len(exported) > 0 { t.Errorf("a rejected setup must export nothing, got:\n%s", exported) } } + +// The self-signed identity stands in for the production key in CI, so it has +// to be what Authenticode demands of a signing certificate: a code-signing +// extended key usage, and a PKCS#12 that opens with the password it was given +// and holds both the key and the certificate. +func TestMakeSelfSignedCodesign(t *testing.T) { + if _, err := exec.LookPath("openssl"); err != nil { + t.Skip("openssl not installed") + } + if _, err := os.ReadFile("make-selfsigned-codesign.sh"); err != nil { + t.Fatal(err) + } + for _, alg := range []string{"ec", "rsa"} { + t.Run(alg, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cmd := exec.Command("bash", "make-selfsigned-codesign.sh", dir, alg) + cmd.Env = append(os.Environ(), "CODESIGN_P12_PASSWORD=test-password") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generate: %v\n%s", err, output) + } + for _, leaked := range []string{"codesign.key", "codesign.cnf"} { + if _, err := os.Stat(filepath.Join(dir, leaked)); !os.IsNotExist(err) { + t.Errorf("%s was left behind: %v", leaked, err) + } + } + cert, err := exec.Command("openssl", "x509", "-in", filepath.Join(dir, "codesign.crt"), "-noout", "-text").CombinedOutput() + if err != nil { + t.Fatalf("read certificate: %v\n%s", err, cert) + } + if !strings.Contains(string(cert), "Code Signing") { + t.Errorf("certificate lacks the code-signing extended key usage:\n%s", cert) + } + p12 := filepath.Join(dir, "codesign.p12") + opened, err := exec.Command("openssl", "pkcs12", "-in", p12, "-passin", "pass:test-password", "-nodes").CombinedOutput() + if err != nil { + t.Fatalf("open pkcs12 with its password: %v\n%s", err, opened) + } + if !strings.Contains(string(opened), "PRIVATE KEY") || !strings.Contains(string(opened), "BEGIN CERTIFICATE") { + t.Errorf("pkcs12 must hold the key and the certificate:\n%s", opened) + } + if exec.Command("openssl", "pkcs12", "-in", p12, "-passin", "pass:wrong", "-nodes").Run() == nil { + t.Error("pkcs12 opened with the wrong password") + } + }) + } +} diff --git a/scripts/verify-windows-signature.ps1 b/scripts/verify-windows-signature.ps1 new file mode 100644 index 0000000..d06cd4f --- /dev/null +++ b/scripts/verify-windows-signature.ps1 @@ -0,0 +1,21 @@ +# verify-windows-signature.ps1 asks Windows itself whether each executable's +# Authenticode signature is valid, timestamped and issued to the expected +# signer. Linux and macOS tools cannot answer this: the trust store that +# matters is Windows'. Used by the release workflow against published zips and +# by the signing smoke test against a self-signed build. +# +# -Path executables to check +# -SignerPattern regex the signer certificate subject must match +param( + [Parameter(Mandatory)][string[]]$Path, + [Parameter(Mandatory)][string]$SignerPattern +) +$ErrorActionPreference = 'Stop' + +foreach ($file in $Path) { + $sig = Get-AuthenticodeSignature -FilePath $file + "${file}: $($sig.Status) $($sig.SignerCertificate.Subject)" + if ($sig.Status -ne 'Valid') { throw "${file}: signature is $($sig.Status): $($sig.StatusMessage)" } + if ($sig.SignerCertificate.Subject -notmatch $SignerPattern) { throw "${file}: unexpected signer $($sig.SignerCertificate.Subject)" } + if ($null -eq $sig.TimeStamperCertificate) { throw "${file}: no timestamp" } +} From 3db417e50550f25b23b200454b3887a3deaa1985 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 29 Oct 2026 18:54:58 +0000 Subject: [PATCH 4/5] ci: pin LF line endings for the Windows checkout Windows CI failed its first run at make check with exit code 2. The runner checks out with core.autocrlf=true, so every file lands with CRLF and the lint step flags all 126 .go files at gofmt -l. Reproduced in a local clone with core.autocrlf=true, fixed there with this file, and rechecked clean. --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b013115 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# The Windows runners check out with core.autocrlf=true, which turns LF +# into CRLF. gofmt then flags every .go file and make lint fails; Git Bash +# also chokes on CRLF shell scripts. Binary files are unaffected by text=auto. +* text=auto eol=lf From 28a2912b676c79b83d803f1fd13de5882e812217 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 29 Oct 2026 20:36:02 +0000 Subject: [PATCH 5/5] docs: name the Windows installer NSIS, not an MSI The Windows team wants NSIS (Nullsoft) when aperture grows an installer, which produces an .exe, so the ADR revisit condition should say so instead of naming an MSI. --- docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md b/docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md index fb3f045..6cb33ab 100644 --- a/docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md +++ b/docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md @@ -88,5 +88,5 @@ as before. The CA will not issue against an EC key, Google Cloud signing latency or quota fails releases, the client team's builder can take over signing for -aperture, or a Windows-only artifact (an MSI) appears that needs a Windows +aperture, or a Windows-only artifact (an NSIS installer) appears that needs a Windows step in the release.