Skip to content

windows build - #56

Open
guygrigsby wants to merge 14 commits into
mainfrom
windows_build
Open

guygrigsby wants to merge 14 commits into
mainfrom
windows_build

Conversation

@guygrigsby

Copy link
Copy Markdown
Contributor
  • ci: add a Windows build and test gate
  • release: sign Windows binaries with Azure Artifact Signing
  • release: sign Windows with a Cloud HSM key through a store-agnostic Jsign hook

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.
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.
…sign 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.
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.
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.
The Windows team has settled on WiX Toolset rather than NSIS for an eventual
aperture installer. WiX builds an MSI, so the revisit condition goes back to
naming one.
The workload identity provider can only assert the release environment, and
the environment can only assert a tag, so a tag pushed from a feature branch
would be signed as Tailscale Inc. like any other. The check runs against HEAD,
not GITHUB_SHA, because a workflow_dispatch run checks out the requested tag
while GITHUB_SHA still names the branch it was dispatched from; checking
GITHUB_SHA would pass a branch tag dispatched from main.

It stops mistakes, not an attacker who can push tags: that tag's workflow file
can simply omit the step. The tag ruleset is the control for that.
Fleet admins deploy with msiexec /qn and expect an Add/Remove Programs entry
and aperture on PATH; a zip gives them neither. One .wxs serves x64 and arm64
because wix build -arch sets the platform, and an MSI installs only on the
platform it names, so each architecture gets its own MSI rather than one
carrying both payloads.

The build script refuses anything but a stable vX.Y.Z inside 255.255.65535,
since Windows Installer has no prerelease field and would misorder one.
UpgradeCode is fixed and pinned by a test: changing it makes the next MSI
install beside the old one instead of upgrading it.

WiX is pinned at 7.0.1, which requires accepting the OSMF EULA (wix7);
Tailscale already sponsors wixtoolset, which is what it asks. Windows CI
installs WiX so the packaging test builds a real MSI and reads its version and
platform back through Windows Installer; elsewhere it skips.
A signed MSI proves nothing about whether it installs, upgrades or comes off
cleanly, and the release has no machine it may install on. The smoke test now
signs the exe, packages two versions, signs both MSIs, has Windows verify all
three, then installs, upgrades, refuses a downgrade and uninstalls on native
hardware for each architecture. Building arm64 on the x64 runner, as before,
would have left the arm64 MSI never installed anywhere.
An MSI has to be built from the signed exe and then signed itself, and WiX
needs Windows, so it cannot happen inside the macOS goreleaser run.
windows-msi replaces verify-windows: it verifies the published exes as before,
then builds, signs and verifies one MSI per architecture and rewrites
checksums.txt to include them. Both jobs refuse a commit not on main before
they log in to Google Cloud.

The release is published before the MSIs exist, so a failure here leaves zips
without MSIs until the job is re-run; every step is idempotent for that.
Making the release a draft until this job finishes would close the gap, but
the Homebrew cask is pushed from the first job and would point at assets that
do not download until the draft is published.
Prerelease tags get no MSI, since MSI versions have no prerelease field.
ADR 0009 said no Windows job sits inside the release and named
verify-windows; neither is true once the MSI has to be built from the signed
exe on Windows. 0010 records why that job exists and what it costs, chiefly a
release that is public before its MSIs are. The README tells release owners
that the main-only check is not the control against someone who can push
tags; the tag ruleset is.
…e key

A manual run executes main's workflow but checks out whatever ref it is
given, and the main check was a script from that checkout. Dispatching from
main with a branch whose copy of the script exits 0 would pass the release
environment, get a signing token and run that branch's setup scripts with it,
the Apple secrets included. The check is now inline in the workflow, before
anything from the checkout runs, and a dispatched ref must be a tag. The
script and its test go, since a copy in the checkout is exactly what cannot
be trusted.

The MSI job also took its exes from the release, which anyone with write
access can swap between jobs for any Tailscale-signed binary; it now takes
goreleaser's from a run artifact. WiX runs before Google Cloud login, signing
is its own step, the auth action writes no credentials file, and GH_TOKEN
reaches only the steps that call gh.
A re-run of the MSI job rebuilds at the same version with a new ProductCode,
and MajorUpgrade ignores same-version products by default, so anyone who
installed the first build would end up with two Add/Remove Programs entries.
Skipping the upload when an MSI is already on the release was the other fix,
but it strands a release whose first upload half failed. The install test now
reinstalls a rebuild between the upgrade and the downgrade.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant