From 947bb8e0207abad92ca64ae53d7a0bf1ace9cd13 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 23 Sep 2026 18:35:12 -0700 Subject: [PATCH] docs: Windows signing is release-only, allowlisted, and gated by branch protection Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d9afab0..c1ea7e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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