Sign Windows releases with Azure Artifact Signing — a 3-day leaf makes timestamping mandatory #228

Merged
enricobuehler merged 2 commits from worktree-azure-trusted-signing into main 2026-08-14 17:20:12 +00:00
Owner

Moves Windows releases off the self-signed CN=unom cert onto Azure Artifact Signing — the service Microsoft renamed from Trusted Signing. Account unomsigning, profile unom-io, signed by the punktfunk-ci-signing service principal.

Verified end-to-end on the runner (.133) as SYSTEM before opening this: sign rc=0, verify rc=0, chain Microsoft Identity Verification Root CA 2020 → ID Verified CS EOC CA 04 → unom - Enrico Buhler, leaf thumbprint DD6A610F242CB5B2078C2A5D628699B6AB0CAC07 matching the profile Azure reports, timestamped.

Three things that would have shipped broken

Timestamping had to stop being best-effort. Azure mints a leaf certificate per signing request that expires in about three days — confirmed, the profile's current cert was issued today and expires on the 17th. Both pack scripts previously retried without a timestamp when a timestamped sign failed. Under Azure that ships an artifact which verifies on the runner and goes untrusted days later, on every user's machine at once. The retry is now gated on the signing mode: still lenient for a .pfx whose cert outlives the release, a hard failure for Azure.

The MSIX publisher must match the signer byte-for-byte, because package identity is Name + Publisher. The default is now the profile's verified subject, written with [char]0xFC escapes rather than literal umlauts — these .ps1 files are UTF-8 without a BOM, and read by anything other than pwsh 7 a literal would silently mojibake the DN into one that no longer matches. pack-msix.ps1 now also reads the signature back off the packed .msix and fails on drift.

That check is asymmetric on purpose: a subject that disagrees is fatal, a subject that cannot be read is only a warning. Get-AuthenticodeSignature's .msix support varies by Windows version and signtool has already reported success by then — turning "the check could not run" into a build break trades a real defect we catch for an imaginary one we invent.

The updater's leaf-pinning note was wrong. update/windows.rs claimed the AUTHENTICODE_SHA256 field made Trusted Signing "a manifest edit". A per-request leaf is exactly what a leaf pin cannot track — a pin would go stale within days and reject every release after it. Corrected in the docs; verification behaviour deliberately unchanged, since tightening it would break canary builds that are still self-signed.

Runner provisioning

Signing failed twice on the runner for reasons neither error named, so both are now provisioned in scripts/ci/provision-windows-punktfunk-extras.ps1, SHA-256 pinned to version-immutable URLs:

  • Azure.CodeSigning.Dlib.dll is mixed-mode C++/CLI and pins Microsoft.NETCore.App 8.0.0. The runner had no .NET runtime at all (pwsh 7 is self-contained), so signtool exited 3 having printed nothing.
  • The client installs machine-wide under C:\trusted-signing, not a user .nuget — act_runner is SYSTEM, whose USERPROFILE is config\systemprofile, so a per-user install is invisible to every job.

Without this, a rebuild from the unom/infra Packer template would silently un-fix both.

Not in scope, on purpose

Drivers keep their own DRIVER_CERT_* cert and the installer still plants it as a machine root. The two signatures were always independent — Windows checks the installer via SmartScreen/UAC and driver catalogs via PnP, and never requires a common signer. Whether a publicly-trusted catalog would let us drop that root plant is recorded as an unverified follow-up in the README, not assumed.

⚠ Merging this changes MSIX package identity

Existing client installs need an uninstall, not an upgrade. This belongs in the release notes for whichever version ships it.

Verification

  • both pack scripts parse under the PowerShell 7 AST parser; both workflows are valid YAML; rustfmt clean
  • the evaluated -Publisher default matches the subject Azure reports for the profile (86 chars, ordinal comparison)
  • signing an unsigned binary on .133 and reading the subject back reproduces pack-msix.ps1's Publisher assertion exactly (match=True) — tested against a non-catalog-signed binary, because Get-AuthenticodeSignature on a catalog-signed system exe returns the catalog signer and reads as a false mismatch
  • the service principal holds one role, Artifact Signing Certificate Profile Signer, scoped to the unom-io profile only; confirmed sufficient by deleting a broader account-scope assignment and re-signing
Moves Windows releases off the self-signed `CN=unom` cert onto **Azure Artifact Signing** — the service Microsoft renamed from Trusted Signing. Account `unomsigning`, profile `unom-io`, signed by the `punktfunk-ci-signing` service principal. Verified end-to-end on the runner (.133) as SYSTEM before opening this: sign rc=0, verify rc=0, chain `Microsoft Identity Verification Root CA 2020 → ID Verified CS EOC CA 04 → unom - Enrico Buhler`, leaf thumbprint `DD6A610F242CB5B2078C2A5D628699B6AB0CAC07` matching the profile Azure reports, timestamped. ## Three things that would have shipped broken **Timestamping had to stop being best-effort.** Azure mints a leaf certificate per signing request that expires in about three days — confirmed, the profile's current cert was issued today and expires on the 17th. Both pack scripts previously retried *without* a timestamp when a timestamped sign failed. Under Azure that ships an artifact which verifies on the runner and goes untrusted days later, on every user's machine at once. The retry is now gated on the signing mode: still lenient for a `.pfx` whose cert outlives the release, a hard failure for Azure. **The MSIX publisher must match the signer byte-for-byte**, because package identity is Name + Publisher. The default is now the profile's verified subject, written with `[char]0xFC` escapes rather than literal umlauts — these `.ps1` files are UTF-8 without a BOM, and read by anything other than pwsh 7 a literal would silently mojibake the DN into one that no longer matches. `pack-msix.ps1` now also reads the signature back off the packed `.msix` and fails on drift. That check is asymmetric on purpose: a subject that *disagrees* is fatal, a subject that *cannot be read* is only a warning. `Get-AuthenticodeSignature`'s `.msix` support varies by Windows version and signtool has already reported success by then — turning "the check could not run" into a build break trades a real defect we catch for an imaginary one we invent. **The updater's leaf-pinning note was wrong.** `update/windows.rs` claimed the `AUTHENTICODE_SHA256` field made Trusted Signing "a manifest edit". A per-request leaf is exactly what a leaf pin cannot track — a pin would go stale within days and reject every release after it. Corrected in the docs; verification behaviour deliberately unchanged, since tightening it would break canary builds that are still self-signed. ## Runner provisioning Signing failed twice on the runner for reasons neither error named, so both are now provisioned in `scripts/ci/provision-windows-punktfunk-extras.ps1`, SHA-256 pinned to version-immutable URLs: - `Azure.CodeSigning.Dlib.dll` is mixed-mode C++/CLI and pins `Microsoft.NETCore.App 8.0.0`. The runner had **no .NET runtime at all** (pwsh 7 is self-contained), so signtool exited **3 having printed nothing**. - The client installs **machine-wide** under `C:\trusted-signing`, not a user `.nuget` — act_runner is SYSTEM, whose `USERPROFILE` is `config\systemprofile`, so a per-user install is invisible to every job. Without this, a rebuild from the unom/infra Packer template would silently un-fix both. ## Not in scope, on purpose Drivers keep their own `DRIVER_CERT_*` cert and the installer still plants it as a machine root. The two signatures were always independent — Windows checks the installer via SmartScreen/UAC and driver catalogs via PnP, and never requires a common signer. Whether a publicly-trusted catalog would let us drop that root plant is recorded as an **unverified** follow-up in the README, not assumed. ## ⚠ Merging this changes MSIX package identity Existing client installs need an **uninstall, not an upgrade**. This belongs in the release notes for whichever version ships it. ## Verification - both pack scripts parse under the PowerShell 7 AST parser; both workflows are valid YAML; rustfmt clean - the evaluated `-Publisher` default matches the subject Azure reports for the profile (86 chars, ordinal comparison) - signing an unsigned binary on .133 and reading the subject back reproduces `pack-msix.ps1`'s Publisher assertion exactly (`match=True`) — tested against a **non**-catalog-signed binary, because `Get-AuthenticodeSignature` on a catalog-signed system exe returns the catalog signer and reads as a false mismatch - the service principal holds one role, *Artifact Signing Certificate Profile Signer*, scoped to the `unom-io` profile only; confirmed sufficient by deleting a broader account-scope assignment and re-signing
enricobuehler added 2 commits 2026-08-14 17:19:20 +00:00
Releases move from the self-signed CN=unom cert to Azure Artifact Signing (formerly Trusted
Signing): account `unomsigning`, profile `unom-io`, signed by the `punktfunk-ci-signing` service
principal, which holds only the Artifact Signing Certificate Profile Signer role scoped to that one
profile. Both pack scripts gain the backend ahead of the existing .pfx and ephemeral fallbacks, so
canary and fork builds are unaffected.

Three things that are easy to get wrong, and are handled here rather than discovered in the field:

Azure mints a leaf certificate per signing request that expires in about three days. Both scripts
previously retried WITHOUT a timestamp when a timestamped sign failed — under Azure that ships an
artifact which verifies on the runner and goes untrusted days later, on every user's machine at
once. The retry is now gated on the mode: still lenient for a .pfx whose cert outlives the release,
a hard failure for Azure.

The MSIX manifest Publisher must equal the signer subject byte-for-byte, because package identity is
Name + Publisher. The default is now the profile's verified subject, written with `[char]0xFC`
escapes rather than literal umlauts so this UTF-8-without-BOM file cannot silently mojibake the DN
into one that no longer matches. pack-msix.ps1 now also reads the signature back off the packed
.msix and fails on drift — asymmetric on purpose: a subject that disagrees is fatal, a subject that
cannot be read is only a warning, since Get-AuthenticodeSignature's .msix support varies by Windows
version and signtool has already reported success by then. NOTE this changes package identity, so
existing installs need an uninstall, not an upgrade.

The updater's leaf-pinning note was wrong and is corrected: update/windows.rs claimed the
AUTHENTICODE_SHA256 field made Trusted Signing "a manifest edit", but a per-request leaf is exactly
what a leaf pin cannot track — a pin would go stale within days and reject every release after it.

Drivers are deliberately untouched: their catalogs keep the DRIVER_CERT_* cert and the installer
still plants it as a machine root. The two signatures were always independent (SmartScreen/UAC vs
PnP), which is why the installer could move without them. Whether a publicly-trusted catalog would
let us drop that root plant is recorded as an unverified follow-up, not assumed.

Verified: both scripts parse under the PowerShell 7 AST parser, both workflows are valid YAML, the
evaluated Publisher default matches the subject Azure reports for the profile (86 chars, ordinal),
rustfmt clean. NOT verified on Windows — the sign path itself needs an on-glass run on .133.
ci(windows): provision the signing toolchain — no .NET runtime meant signtool exited 3 in silence
apple / swift (pull_request) Successful in 2m7s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Failing after 2m16s
ci / web (pull_request) Successful in 2m29s
ci / rust-arm64 (pull_request) Successful in 2m35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m24s
ci / bun-nix (pull_request) Successful in 5m25s
ci / rust (pull_request) Successful in 7m16s
ci / docs-site (pull_request) Successful in 7m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m59s
2d15548e38
Verified the whole Azure signing path on the runner (.133) today and it failed twice, for two
reasons that neither error message named. Both are now provisioned here so a rebuild from the
unom/infra Packer template cannot silently un-fix them.

Azure.CodeSigning.Dlib.dll is a mixed-mode C++/CLI assembly: it ships Ijwhost.dll and a
runtimeconfig.json pinning Microsoft.NETCore.App 8.0.0. The runner had NO .NET runtime at all —
pwsh 7 is a self-contained install and brings no shared runtime — so signtool exited 3 having
printed absolutely nothing. Installing the .NET 8 runtime turned that into a clean sign.

The client itself installs machine-wide under C:\trusted-signing rather than a user's .nuget,
because act_runner runs as SYSTEM, whose USERPROFILE is C:\Windows\System32\config\systemprofile.
A per-user install under Administrator is invisible to every job that actually builds. Confirmed by
resolving Find-AzureDlib from a SYSTEM scheduled task, which is also how the earlier SSH-only
attempts misled: over a network logon New-SelfSignedCertificate hits NTE_PERM, so a control test
that "fails" there proves nothing about how CI will behave.

Both downloads are SHA-256 pinned against version-immutable URLs (nuget.org flat-container and the
dotnet builds CDN), so they fail closed on tampering rather than on every Microsoft patch release —
unlike the BtbN `latest` pin above, which re-rolls. The .NET install uses Start-Process -Wait
because the bundle is a GUI PE that returns instantly under `&`, leaving $LASTEXITCODE unset and
racing the completion check (cost one false failure here).

End-to-end result on .133, as SYSTEM: sign rc=0, verify rc=0, chain Microsoft Identity Verification
Root CA 2020 -> ID Verified CS EOC CA 04 -> "unom - Enrico Buhler", leaf thumbprint
DD6A610F242CB5B2078C2A5D628699B6AB0CAC07 (matches the profile Azure reports), timestamped, leaf
expires in 3 days as expected. Signing an unsigned binary and reading the subject back reproduces
pack-msix.ps1's Publisher assertion exactly (match=True) — checked against a NON-catalog-signed
binary on purpose, because Get-AuthenticodeSignature on a catalog-signed system exe returns the
catalog signer and would have read as a false mismatch.
enricobuehler scheduled this pull request to auto merge when all checks succeed 2026-08-14 17:20:09 +00:00
enricobuehler merged commit be0030f953 into main 2026-08-14 17:20:12 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#228