Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
79 changes: 79 additions & 0 deletions .github/workflows/ci-windows-signing.yaml
Original file line number Diff line number Diff line change
@@ -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")
37 changes: 37 additions & 0 deletions .github/workflows/ci-windows.yaml
Original file line number Diff line number Diff line change
@@ -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
64 changes: 63 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ on:

permissions:
contents: write
# google-github-actions/auth exchanges the run's OIDC token for a Google 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 workload identity provider's attribute condition trusts this
# environment. A ref-based condition would match a different tag each run.
environment: release
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand All @@ -32,6 +37,23 @@ jobs:
with:
go-version-file: go.mod
cache: false
-
name: Log in to Google Cloud
id: gcp
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
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
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:
Expand Down Expand Up @@ -60,7 +82,47 @@ 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()
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: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- 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)" }
$exes = foreach ($zip in $zips) {
$dir = Join-Path $env:RUNNER_TEMP $zip.BaseName
Expand-Archive -Path $zip.FullName -DestinationPath $dir
Join-Path $dir 'aperture.exe'
}
./scripts/verify-windows-signature.ps1 -SignerPattern 'O=Tailscale Inc\.' -Path $exes
10 changes: 7 additions & 3 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,26 @@ 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.
# 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:
- 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:
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

```sh
Expand Down Expand Up @@ -215,7 +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 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. 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: 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` (`<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:

Expand Down
92 changes: 92 additions & 0 deletions docs/adr/0009-windows-signing-with-a-cloud-hsm-key.md
Original file line number Diff line number Diff line change
@@ -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 NSIS installer) appears that needs a Windows
step in the release.
2 changes: 2 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
}
Expand Down
Loading
Loading