CI: sign Windows builds with Azure Artifact Signing #62

Merged
jknapp merged 5 commits from ci/windows-code-signing into main 2026-09-24 02:09:49 +00:00
Showing only changes of commit 947bb8e020 - Show all commits
+26 -5
View File
@@ -838,10 +838,15 @@ the bundle sets it, which is what lets the shell match the window to the entry.
### Windows code signing
Every Windows build — releases (`build-app.yml`) and PR previews (`build-app-preview.yml`) — is
signed with **Azure Artifact Signing**: the app binary, the MSI, the NSIS installer and its
uninstaller. Three scripts do it, and "Verify signatures" fails the job if any `.exe`/`.msi` is
unsigned or untimestamped, so an unsigned installer cannot ship quietly.
Windows **releases** (`build-app.yml`) are signed with **Azure Artifact Signing**: the app binary,
the MSI, the NSIS installer and its uninstaller. Three scripts do it, and "Verify signatures"
fails the job if any of them is unsigned or untimestamped, so an unsigned installer cannot ship
quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml` must not
reference the signing secrets. Two reasons: signing is metered (about 1000 signatures a month,
against roughly 50 preview builds a month), and a PR's workflow runs the PR's own code, so a
secret available there is available to whoever can push a branch. To exercise signing before a
merge, dispatch `build-app.yml` on the branch. Every publishing step there is gated on
`gitea.event_name == 'push'`, so a dispatch builds, signs and verifies without releasing.
- `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client
(`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace,
@@ -852,10 +857,26 @@ unsigned or untimestamped, so an unsigned installer cannot ship quietly.
- `scripts/windows-sign.ps1` is the sign command: `signtool sign /dlib` with SHA-256 and the
Microsoft timestamp server, retried. Credentials never reach a command line — the dlib reads
`AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` from the environment.
**It signs only an allowlist of what ships**, about 4 signatures per release. Tauri also
presents build-time tools: the WiX extension DLLs, the NSIS plugins, and the app binary a
second time for the second bundle type. Signing those would roughly triple the metered count
for no user-visible benefit. If the app ever ships resource DLLs or sidecars, extend the
allowlist, or they will go out unsigned. Tauri reports a failed sign command only as
"failed to run powershell", so the script keeps a transcript (`.code-signing/sign-output.log`,
`signtool /debug` included), and the job prints it on failure.
- `scripts/windows-verify-signatures.ps1` checks `signtool verify /pa` plus a timestamp.
Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`,
`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. Four things are load-bearing:
`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. They are referenced only by the
two Windows steps of `build-app.yml` that need them ("Prepare code signing" and "Build Tauri
app"), never by the preview workflow, never echoed, and never on a command line. The repo is
public, so its Actions logs are too. Gitea masks the secret values, and the signing dlib's
`/debug` output carries no tokens (checked against its strings). Anyone who can push to this
repo can reach the secrets through a workflow file, so repo write access is the boundary.
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), so a signed
release only ever comes from a merged, visible change. The
Azure side should hold the rest: an app registration with only the signer role on this one
certificate profile, and a client secret with an expiry. Four things are load-bearing:
- **`metadata.json` excludes every credential but `EnvironmentCredential`.** The dlib uses
`DefaultAzureCredential`, whose chain ends in `InteractiveBrowserCredential`; the runners run as