diff --git a/.gitea/workflows/windows-client.yml b/.gitea/workflows/windows-client.yml index 684a0891..d0d8e528 100644 --- a/.gitea/workflows/windows-client.yml +++ b/.gitea/workflows/windows-client.yml @@ -257,6 +257,19 @@ jobs: if: github.event_name != 'pull_request' shell: pwsh env: + # Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_* + # when all three are set. Not secret: an account/profile name and a regional endpoint, + # inert without the credentials below. The profile's verified subject is also the MSIX + # manifest Publisher; pack-msix.ps1 reads the signature back and fails on a mismatch. + AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/ + AZURE_CODESIGNING_ACCOUNT: unomsigning + AZURE_CODESIGNING_PROFILE: unom-io + # Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate + # Profile Signer role, scoped to the unom-io profile — it can sign and nothing else. + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + # Legacy self-signed path, kept as the fallback for builds without Azure access. MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }} MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }} run: | diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index 8e30c1d1..1047fbeb 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -20,12 +20,18 @@ # main push / dispatch -> . (canary; `canary/` alias; base one minor # ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs). # -# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them -# an ephemeral self-signed cert is generated and its public .cer published next to the installer -# (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag +# Signing goes through Azure Artifact Signing (account `unomsigning`, profile `unom-io`) — a publicly +# trusted CA, so there is no .cer for users to import and no SmartScreen "unknown publisher" prompt. +# It falls back to the old MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD self-signed cert, and then to an +# ephemeral one, for builds without Azure access. Those fallbacks are for canary/CI ONLY — on a v* tag # the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert. # See packaging/windows/pack-host-installer.ps1. # +# The bundled DRIVERS are NOT signed by Azure — they keep their own DRIVER_CERT_* cert and are still +# trusted by planting that cert in the machine Root store at install time. Independent by design: +# Windows checks the installer's signature via SmartScreen/UAC and driver catalogs via PnP, and never +# requires a common signer. See packaging/windows/README.md for why that root-plant is still there. +# # GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer. # - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at # RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on @@ -415,12 +421,26 @@ jobs: - name: Pack + sign installer shell: pwsh env: + # Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_* + # when all three of these are set. Not secret: an account/profile name and a regional + # endpoint, all inert without the credentials below, so they live here where a reviewer + # can see which profile a release was signed by. + AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/ + AZURE_CODESIGNING_ACCOUNT: unomsigning + AZURE_CODESIGNING_PROFILE: unom-io + # Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate + # Profile Signer role, scoped to the unom-io profile — it can sign and nothing else. + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + # Legacy self-signed path, kept as the fallback for builds without Azure access. MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }} MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }} # The DRIVER cert is separate from the host/MSIX one and reaches the two driver build # scripts through the environment (pack-host-installer.ps1 invokes them, they read # $env:DRIVER_CERT_PFX_B64 themselves). Without it they sign with a per-build throwaway, # which the installer then trusts as a machine root — see packaging/windows/README.md. + # NOT moved to Azure: driver catalogs are a separate track, see that README. DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }} DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }} run: | diff --git a/clients/windows/packaging/AppxManifest.xml b/clients/windows/packaging/AppxManifest.xml index 68985625..82c9ef2f 100644 --- a/clients/windows/packaging/AppxManifest.xml +++ b/clients/windows/packaging/AppxManifest.xml @@ -3,8 +3,14 @@ MSIX package manifest for the punktfunk Windows client (WinUI 3 via windows-reactor). This is a TEMPLATE: packaging/pack-msix.ps1 substitutes {VERSION} (4-part numeric, e.g. - 0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — default - `CN=unom` for the self-signed CI cert; a real code-signing cert just passes its own subject). + 0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — the default is + the verified subject of the Azure `unom-io` certificate profile; the self-signed fallback mints + a throwaway cert with that same subject so canary and release share a package identity). + + Package identity is Name + Publisher, so changing {PUBLISHER} makes this a DIFFERENT package: + installs of the older publisher cannot be upgraded in place and must be uninstalled first. That + is a user-visible migration, not a packaging detail — mention it in the release notes. pack-msix.ps1 + reads the signature back off the packed .msix and fails the build if the two ever drift. Why this packages cleanly even though the app was built "unpackaged": windows-reactor calls MddBootstrapInitialize2 with OnPackageIdentity_NOOP (crates/libs/reactor/src/app.rs), so under diff --git a/clients/windows/packaging/README.md b/clients/windows/packaging/README.md index 666ef7d6..6c22343c 100644 --- a/clients/windows/packaging/README.md +++ b/clients/windows/packaging/README.md @@ -56,34 +56,45 @@ MSIX requires a strictly 4-part numeric version. The workflow computes: ## Signing & install -CI signs every build with a **stable self-signed code-signing cert** (`CN=unom`, SHA-1 -`CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`, valid to 2036). Its public half is checked in as -[`punktfunk-codesign.cer`](punktfunk-codesign.cer); the private `.pfx` + password live in the -`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` Actions secrets. Because it's the *same* cert every build, -trusting it is **one-time, per machine** — once imported, every future build and in-place upgrade is -trusted with no further prompt: +CI signs every build with **Azure Artifact Signing** (formerly Trusted Signing) — account +`unomsigning`, certificate profile `unom-io`, endpoint `https://neu.codesigning.azure.net/`. That +chain is publicly trusted, so **there is nothing to import**: ```powershell -# once per machine (elevated): trust the publisher -Import-Certificate -FilePath .\punktfunk-codesign.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople -# then install the package for your CPU (and re-run for each upgrade — no re-trust needed) +# install the package for your CPU (and re-run for each upgrade) Add-AppxPackage -Path .\punktfunk-client-windows__x64.msix # Intel/AMD Add-AppxPackage -Path .\punktfunk-client-windows__arm64.msix # ARM64 (Snapdragon, etc.) ``` -The matching `.cer` is also published next to each `.msix` in the registry, so it's always at hand. - The MSIX declares a dependency on the Windows App SDK 2.x runtime; install [the App SDK runtime](https://aka.ms/windowsappsdk) if `Add-AppxPackage` reports a missing `Microsoft.WindowsAppRuntime.2` framework. -`pack-msix.ps1` signing precedence: it uses the **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** secrets -when present (the stable cert above), else generates an *ephemeral* self-signed cert (forks / local -builds without the secrets). Either way it exports the signing cert's public `.cer` for the import. -**To move to a publicly-trusted (no-import) cert** — Azure Artifact Signing or a public OV cert — -replace the two secrets with the new `.pfx`; the cert's subject DN must equal the manifest -`Publisher`, so pass a matching `-Publisher` (it's stamped into the package `Identity`, and changing -it changes the package identity → a one-time reinstall). +### How signing resolves + +`pack-msix.ps1` picks a backend in this order: + +1. **Azure Artifact Signing** when `AZURE_CODESIGNING_ENDPOINT` / `_ACCOUNT` / `_PROFILE` are all + set (the workflow sets them; they aren't secret). Credentials come from `AZURE_TENANT_ID` / + `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` — the `punktfunk-ci-signing` service principal, which + holds only the *Artifact Signing Certificate Profile Signer* role scoped to the `unom-io` profile. + Keys are HSM-backed and never leave Azure, so there is no `.pfx` and no `.cer` is emitted. +2. **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** — the older stable self-signed cert (`CN=unom`, + public half checked in as [`punktfunk-codesign.cer`](punktfunk-codesign.cer)), kept as a fallback. +3. An **ephemeral** self-signed cert (forks / local builds with no secrets at all). + +Modes 2 and 3 still export a `.cer` to import into `Cert:\LocalMachine\TrustedPeople` first. On a +`v*` tag, a build with no real signing backend **fails closed** rather than shipping a throwaway. + +Two things about Azure mode that are easy to get wrong: + +- **Timestamping is mandatory, not best-effort.** Azure mints a leaf cert per request that expires in + about three days. An untimestamped signature therefore stops verifying within days of release, so + the script refuses to retry without one (modes 2 and 3 keep the old best-effort retry). +- **The manifest `Publisher` must equal the signer's subject exactly**, because MSIX package identity + is Name + Publisher. The default `-Publisher` is the `unom-io` profile's verified subject; after + signing, the script reads the signature back off the `.msix` and fails the build on any drift. + Changing it makes a *different* package — existing installs must be uninstalled, not upgraded. ## Building locally diff --git a/clients/windows/packaging/pack-msix.ps1 b/clients/windows/packaging/pack-msix.ps1 index 807ee550..be808431 100644 --- a/clients/windows/packaging/pack-msix.ps1 +++ b/clients/windows/packaging/pack-msix.ps1 @@ -13,15 +13,22 @@ packaging/windows/pack-host-installer.ps1 still ships them for its amf-qsv encode path. Signing cert precedence: + 0. Azure Artifact Signing (formerly Trusted Signing) when AZURE_CODESIGNING_ENDPOINT/_ACCOUNT/ + _PROFILE are all set. HSM-backed, so there is no .pfx and nothing to export: the chain is + publicly trusted, so no .cer is produced and MSIX_CER_PATH stays unset. 1. -PfxBase64 / -PfxPassword (a real or shared code-signing cert, e.g. from CI secrets) — the cert's subject DN MUST match -Publisher (which is stamped into the manifest Identity). 2. otherwise an EPHEMERAL self-signed code-signing cert with subject = -Publisher is generated in-process. The package installs only where that cert is trusted, so the matching public .cer is exported next to the .msix for the user to import (Trusted People) before install. - Swap in a real cert later with zero manifest changes — just pass -PfxBase64/-Publisher. This fallback is for canary/CI/dev ONLY: on a v* tag build a missing cert is a hard failure (-RequireSignedCert), never a silent downgrade to a throwaway cert. + WHICHEVER mode runs, the signed .msix is read back and its signer subject compared to -Publisher; + a mismatch fails the build. MSIX package identity is Name + Publisher, so a publisher that does + not match the signer is not a cosmetic problem — Add-AppxPackage rejects the package outright, + and it would only be discovered by a user trying to install the release. + Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present. .EXAMPLE @@ -36,9 +43,21 @@ param( [Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe) [ValidateSet('x64', 'arm64')][string]$Arch = 'x64', # package ProcessorArchitecture + artifact suffix [string]$OutDir = (Join-Path $TargetDir 'msix'), - [string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN + # MUST equal the signing cert subject DN — this is the verified subject the Azure 'unom-io' + # certificate profile issues. The 'ü' is written as an escape, not a literal: this file is UTF-8 + # with no BOM, and read by anything other than pwsh 7 a literal would silently mojibake into a + # publisher that no longer matches the signer, which surfaces only as an Add-AppxPackage refusal + # on a user's machine. Verified against the real signer after signing below. + [string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE", [string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx [string]$PfxPassword = $env:MSIX_CERT_PASSWORD, + # Azure Artifact Signing. All three select it, ahead of any .pfx. Credentials arrive through the + # environment via DefaultAzureCredential (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET) + # rather than as arguments, so they cannot leak into a process listing or a transcript. + [string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT, # e.g. https://neu.codesigning.azure.net/ + [string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT, # signing account name + [string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE, # certificate profile name + [string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB, # path to Azure.CodeSigning.Dlib.dll # 'auto' (default) = required iff this is a v* tag build; 'true'/'false' to force. See below. [ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto' ) @@ -64,6 +83,28 @@ function Find-SdkTool([string]$name) { if (-not $hit) { throw "$name not found under $root — install the Windows 10/11 SDK." } $hit.FullName } +# Azure.CodeSigning.Dlib.dll ships in the Microsoft.Trusted.Signing.Client NuGet package, which has +# no installer and no fixed location — hence an explicit override first, then the paths the runner +# setup uses (packaging/windows/README.md). Newest wins, so a package update needs no edit here. +function Find-AzureDlib([string]$Explicit) { + if ($Explicit) { + if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" } + return (Resolve-Path $Explicit).Path + } + $roots = @( + (Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'), + 'C:\trusted-signing\microsoft.trusted.signing.client' + ) | Where-Object { $_ -and (Test-Path $_) } + $hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } | + Where-Object { $_.FullName -match '\\bin\\x64\\' } | + Sort-Object LastWriteTime | Select-Object -Last 1 + if (-not $hit) { + throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " + + "``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " + + "`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.") + } + $hit.FullName +} $makeappx = Find-SdkTool 'makeappx.exe' $signtool = Find-SdkTool 'signtool.exe' Write-Host "makeappx: $makeappx" @@ -159,13 +200,34 @@ $requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/ else { [Convert]::ToBoolean($RequireSignedCert) } $pfxPath = Join-Path $OutDir 'signing.pfx' $cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.cer" -if ($PfxBase64) { +$azureMetadata = Join-Path $OutDir 'azure-codesigning.json' +$signMode = 'selfsigned' +if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) { + $signMode = 'azure' + $AzureDlib = Find-AzureDlib $AzureDlib + # signtool takes the account/profile from this file (/dmdf), not the command line. + @{ + Endpoint = $AzureEndpoint + CodeSigningAccountName = $AzureAccount + CertificateProfileName = $AzureProfile + } | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8 + Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint" + Write-Host " dlib: $AzureDlib" + foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') { + if (-not [Environment]::GetEnvironmentVariable($v)) { + throw ("Azure signing selected but $v is not set. The dlib authenticates with " + + "DefaultAzureCredential; without the service-principal trio it falls through to an " + + "interactive login that cannot complete on a runner and hangs the build.") + } + } +} elseif ($PfxBase64) { + $signMode = 'pfx' Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)" [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64)) } elseif ($requireCert) { - throw ("release build ($env:GITHUB_REF) with no MSIX_CERT_PFX_B64 — refusing to fall back to an " + - "ephemeral self-signed cert. Restore the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD repo " + - "secrets, or pass -RequireSignedCert false if this really is a test build.") + throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 — " + + "refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " + + "(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.") } else { Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)" if (-not $PfxPassword) { $PfxPassword = 'punktfunk' } @@ -178,35 +240,80 @@ if ($PfxBase64) { Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force } -# Always export the public .cer from the pfx. For a self-signed / private-trust cert it's the file -# users import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that -# import is a one-time, per-machine step that keeps working across upgrades. For a public-CA cert -# it's just an unused extra (harmless). The manifest Publisher must equal the cert's subject DN. -$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null } -$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath } -Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null -Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)" -if ($pubCert.Subject -ne $Publisher) { - Write-Warning "cert subject '$($pubCert.Subject)' != manifest Publisher '$Publisher' — Add-AppxPackage will reject the mismatch. Pass -Publisher '$($pubCert.Subject)'." +# Export the public .cer from the pfx. For a self-signed / private-trust cert it's the file users +# import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that +# import is a one-time, per-machine step that keeps working across upgrades. Azure signing is +# HSM-backed: there is no pfx to read and its chain is publicly trusted, so no .cer is produced. +if ($signMode -ne 'azure') { + $pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null } + $pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath } + Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null + Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)" } -# --- sign (timestamp best-effort) --- -$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath) -if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) } -& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $msix)) +# --- sign --- +# The timestamp is best-effort for a .pfx whose cert outlives the release, but MANDATORY under Azure +# signing: those leaf certs are minted per request and expire in ~3 days, so an untimestamped +# signature stops verifying within days of shipping. Retrying without one there would produce a +# package that installs on the runner and fails for every user that weekend — so the fallback is +# gated on the mode rather than applied blindly. +if ($signMode -eq 'azure') { + $signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata) + $ts = 'http://timestamp.acs.microsoft.com' +} else { + $signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath) + if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) } + $ts = 'http://timestamp.digicert.com' +} +& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $msix)) if ($LASTEXITCODE -ne 0) { + if ($signMode -eq 'azure') { + throw ("timestamped sign failed ($LASTEXITCODE) — NOT retrying without a timestamp. An Azure " + + "signing cert is valid for ~3 days; an untimestamped signature would go untrusted " + + "within days of release.") + } Write-Warning "timestamped sign failed — retrying without a timestamp" & $signtool ($signArgs + @($msix)) if ($LASTEXITCODE -ne 0) { throw "signtool sign failed ($LASTEXITCODE)" } } Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue +Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue + +# Read the signature back off the packed .msix and hold it against the manifest Publisher. MSIX +# package identity is Name + Publisher, so a publisher that doesn't match the signer isn't cosmetic: +# Add-AppxPackage refuses the package outright. Checking the ACTUAL signer (rather than a pfx we +# happen to hold) is the only form of this check that works in every signing mode, and failing the +# build here is the difference between a red pipeline and a release nobody can install. +# Deliberately asymmetric: a subject we CAN read and that DISAGREES is a hard failure, but a subject +# we cannot read at all is only a warning. Get-AuthenticodeSignature's support for the .msix/.appx +# subject interface varies by Windows version, and signtool has already reported success by this +# point — turning "the check could not run" into a build break would trade a real defect we catch for +# an imaginary one we invent. +$signerSubject = $null +try { $signerSubject = (Get-AuthenticodeSignature $msix).SignerCertificate.Subject } catch { } +if (-not $signerSubject) { + Write-Warning ("could not read a signer subject back from $msix, so Publisher/signer agreement is " + + "UNVERIFIED on this box. If the package is rejected at Add-AppxPackage time, compare " + + "`signtool verify /pa /v` against the manifest Publisher '$Publisher' by hand.") +} elseif ($signerSubject -ne $Publisher) { + throw ("signer subject does not match the manifest Publisher, so this package cannot install:`n" + + " signer : '$signerSubject'`n" + + " Publisher : '$Publisher'`n" + + "Pass -Publisher '$signerSubject' (or fix the certificate profile) and repack.") +} else { + Write-Host "verified signer subject matches manifest Publisher: $signerSubject" +} Write-Host "" Write-Host "==> MSIX: $msix" -Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):" -Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople" +if ($signMode -eq 'azure') { + Write-Host "==> signed by a publicly trusted CA — nothing for users to import." +} else { + Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):" + Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople" +} # emit paths for the workflow to publish (only under CI, where GITHUB_ENV is set) if ($env:GITHUB_ENV) { "MSIX_PATH=$msix" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + if ($signMode -ne 'azure') { "MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } } diff --git a/crates/punktfunk-host/src/update/windows.rs b/crates/punktfunk-host/src/update/windows.rs index 66b5ca28..ab0413d6 100644 --- a/crates/punktfunk-host/src/update/windows.rs +++ b/crates/punktfunk-host/src/update/windows.rs @@ -7,12 +7,20 @@ //! 1. **SHA-256 == the signed manifest's** — the primary integrity gate (the manifest is the //! Ed25519-verified document; this check makes the downloaded bytes those exact bytes). //! 2. **Authenticode**: the embedded signature must be cryptographically valid, tolerating -//! `CERT_E_UNTRUSTEDROOT` while the shipping cert is self-signed (`CN=unom`); when the +//! `CERT_E_UNTRUSTEDROOT` (canary and local builds still sign with a self-signed cert, and +//! releases moved to Azure Artifact Signing without needing this to tighten); when the //! manifest carries leaf pins, the signing leaf's SHA-256 must match one. The leaf is taken //! from the SAME `WinVerifyTrust` state (`WTHelperGetProvSignerFromChain`), never a second //! parse — no verify-vs-inspect gap. An empty pin list skips only the pin comparison (the -//! manifest hash already binds content; pins arrive via `AUTHENTICODE_SHA256` in CI once -//! the cert story settles — the field exists so Trusted Signing is a manifest edit). +//! manifest hash already binds content). +//! +//! **Leaf pinning cannot be used with Azure Artifact Signing.** That service mints a fresh leaf +//! per signing request, valid ~3 days, so an `AUTHENTICODE_SHA256` pin would go stale within days +//! of publishing and reject every subsequent release. (An earlier note here assumed the opposite — +//! that the pin field made Trusted Signing "a manifest edit". It does not.) If pinning is wanted +//! against the Azure-signed artifacts, pin something stable instead: the issuing intermediate, or +//! the certificate subject. Leave the list empty until then; the Ed25519-signed manifest hash is +//! what actually binds the downloaded bytes. //! //! The spawn uses `CREATE_BREAKAWAY_FROM_JOB`: the service worker's job object is kill-on-close //! (a stopping service would otherwise take the installer down with it) and was created @@ -284,8 +292,10 @@ fn preflight_disk(at: &Path, needed: u64) -> Result<(), String> { Ok(()) } -/// Authenticode: valid embedded signature (untrusted root tolerated — self-signed `CN=unom`), -/// signing-leaf SHA-256 ∈ `pins` when pins are present. The leaf comes out of the same +/// Authenticode: valid embedded signature (untrusted root tolerated — canary/local builds are still +/// self-signed), signing-leaf SHA-256 ∈ `pins` when pins are present — but see the module docs: a +/// leaf pin is unusable against Azure-signed releases, whose leaf rotates every few days. The leaf +/// comes out of the same /// `WinVerifyTrust` state via `WTHelperGetProvSignerFromChain`. (`pub(crate)`: the service /// supervisor's boot-loop rollback re-checks the cached previous installer with it.) pub(crate) fn verify_authenticode(path: &Path, pins: &[String]) -> Result<(), String> { diff --git a/packaging/windows/README.md b/packaging/windows/README.md index fcffaf61..6088c978 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -140,8 +140,61 @@ fresh install uses the generated random console password — read it from > punktfunk-planning: `windows-build-and-packaging.md` (internal planning repo) for the toolchain > + signing details. +## Installer signing (Azure Artifact Signing) + +`setup.exe`, `punktfunk-host.exe`, `punktfunk-tray.exe` and the Vulkan HDR layer are signed with +**Azure Artifact Signing** (formerly Trusted Signing): account `unomsigning`, certificate profile +`unom-io`, endpoint `https://neu.codesigning.azure.net/`. It is a publicly trusted CA, so users get +a named publisher in the UAC prompt and there is no `.cer` to import — `HOST_CER_PATH` is simply not +emitted in this mode (every consumer already guards on `Test-Path`). + +`pack-host-installer.ps1` resolves a backend in this order, first match wins: + +| order | backend | selected by | +| --- | --- | --- | +| 1 | Azure Artifact Signing | `AZURE_CODESIGNING_ENDPOINT` + `_ACCOUNT` + `_PROFILE` all set | +| 2 | stable self-signed `.pfx` | `MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` | +| 3 | ephemeral self-signed | nothing set (canary / local only; a `v*` tag **fails closed**) | + +Credentials for mode 1 come from the environment via `DefaultAzureCredential` — `AZURE_TENANT_ID`, +`AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, the `punktfunk-ci-signing` service principal. It holds +exactly one role, *Artifact Signing Certificate Profile Signer*, scoped to the `unom-io` profile: it +can sign and can do nothing else with the subscription. The script hard-fails if the trio is missing +rather than letting `DefaultAzureCredential` fall through to an interactive login that would hang a +runner forever. + +> **Timestamping is mandatory here, not best-effort.** Azure mints a leaf certificate per request, +> valid for about three days. An untimestamped signature therefore goes untrusted within days of +> release — it would verify fine on the runner and fail on users' machines that weekend. `Sign-File` +> refuses to retry without a timestamp in Azure mode; modes 2 and 3 keep the old lenient retry, where +> the cert outlives the release anyway. + +### Runner setup + +`signtool` reaches Azure through `Azure.CodeSigning.Dlib.dll`, which ships in the +`Microsoft.Trusted.Signing.Client` NuGet package — no installer, no fixed path. On the Windows runner: + +```powershell +nuget install Microsoft.Trusted.Signing.Client -OutputDirectory $env:USERPROFILE\.nuget\packages +``` + +`Find-AzureDlib` searches that path and `C:\trusted-signing\`, newest first, so a package update needs +no script edit. Set `AZURE_CODESIGNING_DLIB` to override with an explicit path. + ## Driver signing (`DRIVER_CERT_PFX_B64`) +> **The drivers are deliberately NOT on Azure.** Their catalogs keep the self-signed +> `CN=punktfunk-driver` cert below, which the installer still plants in the machine `Root` store. +> The two signatures are independent by design — Windows verifies the installer via SmartScreen/UAC +> and driver catalogs via PnP, and never requires a common signer, which is why the installer could +> move to a public CA without touching the driver track at all. +> +> Worth revisiting: these are **user-mode** (UMDF) drivers and we already clear `FORCE_INTEGRITY`, so +> a catalog signed by the publicly trusted Azure cert would likely chain to a root every Windows box +> already has — which would let us drop the `Root` plant entirely and keep only the `TrustedPublisher` +> entry that suppresses the device-software prompt. That is a real reduction in what we ask of a +> user's machine, but it is **unverified**: test it on the Windows box before believing it. + Our three UMDF drivers are signed with a **stable self-signed code-signing cert**, subject `CN=punktfunk-driver`, supplied to `build-pf-vdisplay.ps1` / `build-gamepad-drivers.ps1` as the `DRIVER_CERT_PFX_B64` + `DRIVER_CERT_PASSWORD` Actions secrets. On a `v*` tag build a missing cert @@ -241,7 +294,8 @@ the recovery. From a Linux box drive either over SSH, e.g. # statically links the vendored VPL dispatcher — needs cmake + a libclang, no FFmpeg) cargo build --release -p punktfunk-host --features nvenc,qsv -# 2. pack (self-signed unless MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD are set; -NoDriver to skip pf-vdisplay) +# 2. pack (self-signed unless the AZURE_CODESIGNING_* trio or MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD +# are set — see "Installer signing" above; -NoDriver to skip pf-vdisplay) pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetDir C:\t\release -OutDir C:\t\out ``` diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 index 711ecdf6..9d011bf6 100644 --- a/packaging/windows/pack-host-installer.ps1 +++ b/packaging/windows/pack-host-installer.ps1 @@ -4,15 +4,24 @@ .DESCRIPTION From a release `cargo build -p punktfunk-host --features nvenc` output (the exe), this: - 1. resolves a code-signing cert (supplied stable .pfx from CI secrets OR an ephemeral self-signed - CN=unom - same scheme as the client's pack-msix.ps1) and exports the public .cer. The - ephemeral fallback is for canary/CI/dev ONLY: on a v* tag build a missing cert (or -NoSign) - is a hard failure, never a silent downgrade to a throwaway cert - see -RequireSignedCert, + 1. resolves a signing backend - Azure Artifact Signing (formerly Trusted Signing) when the + AZURE_CODESIGNING_* trio is set, else a supplied stable .pfx from CI secrets, else an + ephemeral self-signed CN=unom - same scheme as the client's pack-msix.ps1. The .pfx paths + also export the public .cer; Azure does not (see below). The ephemeral fallback is for + canary/CI/dev ONLY: on a v* tag build a missing cert (or -NoSign) is a hard failure, never + a silent downgrade to a throwaway cert - see -RequireSignedCert, 2. signs the inner punktfunk-host.exe, 3. stages the pf-vdisplay virtual-display driver bundle (unless -NoDriver), 4. runs ISCC to build punktfunk-host-setup-.exe, - 5. signs the setup.exe (timestamp best-effort), - 6. emits HOST_SETUP_PATH / HOST_CER_PATH to GITHUB_ENV for the publish step. + 5. signs the setup.exe (timestamped - MANDATORY under Azure signing, see Sign-File), + 6. emits HOST_SETUP_PATH / HOST_CER_PATH to GITHUB_ENV for the publish step. Azure signing + emits no .cer: the chain is publicly trusted, so there is nothing for a user to import. + Every consumer of HOST_CER_PATH already guards on Test-Path, so it is simply absent. + + NOTE the drivers are signed separately, by build-pf-vdisplay.ps1 / build-gamepad-drivers.ps1 with + the DRIVER_CERT_* secret, and are NOT re-signed here (that would invalidate their catalogs). The + installer's signature and the driver catalogs' signatures are independent by design - Windows + verifies the first via SmartScreen/UAC and the second via PnP, and never requires a common signer. Idempotent; safe to re-run. Run on the Windows runner / dev box (MSVC + Windows SDK + Inno Setup). @@ -24,9 +33,20 @@ param( [Parameter(Mandatory = $true)][string]$Version, # e.g. 0.2.137 or 1.4.0 (free-form) [Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release dir (has punktfunk-host.exe) [string]$OutDir = (Join-Path $TargetDir 'installer'), + # Subject for the EPHEMERAL self-signed fallback only. Azure signing carries its own subject + # (the profile's verified CN/O), and nothing downstream of setup.exe compares the two - unlike + # the MSIX, whose manifest Identity/@Publisher must match byte-for-byte. See pack-msix.ps1. [string]$Publisher = 'CN=unom', [string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret [string]$PfxPassword = $env:MSIX_CERT_PASSWORD, + # Azure Artifact Signing (formerly Trusted Signing). All three must be set to select it; it then + # takes precedence over any .pfx. Credentials come from the environment via DefaultAzureCredential + # (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET) - never passed as arguments, so they + # cannot leak into a process listing or a transcript. + [string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT, # e.g. https://neu.codesigning.azure.net/ + [string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT, # signing account name + [string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE, # certificate profile name + [string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB, # path to Azure.CodeSigning.Dlib.dll [string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build) [string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console [string]$ScriptingBundle = $env:SCRIPTING_BUNDLE, # built runner-cli.js -> bundle the plugin/script runner @@ -70,6 +90,29 @@ function Find-SdkTool([string]$name) { if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." } $hit.FullName } +# Azure.CodeSigning.Dlib.dll ships in the Microsoft.Trusted.Signing.Client NuGet package, which has no +# installer and no fixed location - hence an explicit override first, then the two paths the runner +# setup uses (see packaging/windows/README.md). Newest version wins so a package update is picked up +# without editing this script. +function Find-AzureDlib([string]$Explicit) { + if ($Explicit) { + if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" } + return (Resolve-Path $Explicit).Path + } + $roots = @( + (Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'), + 'C:\trusted-signing\microsoft.trusted.signing.client' + ) | Where-Object { $_ -and (Test-Path $_) } + $hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } | + Where-Object { $_.FullName -match '\\bin\\x64\\' } | + Sort-Object LastWriteTime | Select-Object -Last 1 + if (-not $hit) { + throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " + + "``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " + + "`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.") + } + $hit.FullName +} $iscc = Find-Iscc Write-Host "ISCC: $iscc" @@ -87,20 +130,43 @@ if ($NoSign -and $requireCert) { } $pfxPath = Join-Path $OutDir 'signing.pfx' $cerPath = Join-Path $OutDir "punktfunk-host-windows_${Version}.cer" +$azureMetadata = Join-Path $OutDir 'azure-codesigning.json' +$signMode = 'none' $signtool = $null if (-not $NoSign) { $signtool = Find-SdkTool 'signtool.exe' Write-Host "signtool: $signtool" - if ($PfxBase64) { + if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) { + $signMode = 'azure' + $AzureDlib = Find-AzureDlib $AzureDlib + # signtool reads the account/profile from this file (/dmdf) rather than the command line. + @{ + Endpoint = $AzureEndpoint + CodeSigningAccountName = $AzureAccount + CertificateProfileName = $AzureProfile + } | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8 + Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint" + Write-Host " dlib: $AzureDlib" + foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') { + if (-not [Environment]::GetEnvironmentVariable($v)) { + throw ("Azure signing selected but $v is not set. The dlib authenticates with " + + "DefaultAzureCredential; without the service-principal trio it falls through to " + + "an interactive login that cannot complete on a runner and hangs the build.") + } + } + } + elseif ($PfxBase64) { + $signMode = 'pfx' Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)" [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64)) } elseif ($requireCert) { - throw ("release build ($env:GITHUB_REF) with no MSIX_CERT_PFX_B64 - refusing to fall back to " + - "an ephemeral self-signed cert. Restore the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD " + - "repo secrets, or pass -RequireSignedCert false if this really is a test build.") + throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " + + "refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " + + "(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.") } else { + $signMode = 'selfsigned' Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)" if (-not $PfxPassword) { $PfxPassword = 'punktfunk' } $tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher ` @@ -111,25 +177,44 @@ if (-not $NoSign) { Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force } - # Always export the public .cer. For a self-signed cert it's the file users import once - # (LocalMachine\TrustedPublisher) so SmartScreen/UAC trusts the signed setup.exe; for a real CA - # cert it's a harmless extra. - $pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null } - $pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath } - Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null - Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)" + # Export the public .cer for the .pfx-backed modes. For a self-signed cert it's the file users + # import once (LocalMachine\TrustedPublisher) so SmartScreen/UAC trusts the signed setup.exe. + # Azure signing has no .pfx to read and needs no import - the chain is publicly trusted - so it + # deliberately produces no .cer and HOST_CER_PATH stays unset. + if ($signMode -ne 'azure') { + $pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null } + $pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath } + Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null + Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)" + } } +# A timestamp is best-effort for a .pfx whose cert outlives the release, but MANDATORY under Azure +# signing: those leaf certs are minted per request and expire in ~3 days, so an untimestamped +# signature stops verifying within days of shipping. Retrying without one there would produce an +# artifact that passes on the runner and fails on every user's machine that weekend - so the +# fallback is gated on the mode rather than applied blindly. function Sign-File([string]$Path) { if ($NoSign) { return } - $signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath) - if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) } - & $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $Path)) - if ($LASTEXITCODE -ne 0) { - Write-Warning "timestamped sign failed for $Path - retrying without a timestamp" - & $signtool ($signArgs + @($Path)) - if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" } + if ($signMode -eq 'azure') { + $signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata) + $ts = 'http://timestamp.acs.microsoft.com' } + else { + $signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath) + if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) } + $ts = 'http://timestamp.digicert.com' + } + & $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path)) + if ($LASTEXITCODE -eq 0) { return } + if ($signMode -eq 'azure') { + throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " + + "An Azure signing cert is valid for ~3 days; an untimestamped signature would go " + + "untrusted within days of release.") + } + Write-Warning "timestamped sign failed for $Path - retrying without a timestamp" + & $signtool ($signArgs + @($Path)) + if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" } } # --- sign the inner exes before they're packed ------------------------------------------------- @@ -340,14 +425,18 @@ if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" } # --- sign the setup.exe + clean up ------------------------------------------------------------ Sign-File $setup Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue +Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue Write-Host "" Write-Host "==> installer: $setup" -if (-not $NoSign) { +if ($signMode -eq 'azure') { + Write-Host "==> signed by a publicly trusted CA - nothing for users to import." +} +elseif (-not $NoSign) { Write-Host "==> trust the cert once per machine (self-signed builds), then the signed setup.exe is trusted:" Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPublisher" } if ($env:GITHUB_ENV) { "HOST_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - if (-not $NoSign) { "HOST_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } + if (-not $NoSign -and $signMode -ne 'azure') { "HOST_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } } diff --git a/packaging/winget/README.md b/packaging/winget/README.md index b4e97c9d..15059f4a 100644 --- a/packaging/winget/README.md +++ b/packaging/winget/README.md @@ -107,10 +107,10 @@ These manifests stay in winget-pkgs' own format rather than a bespoke one, so su later is a copy, not a rewrite. Two things would need attention on that path: the signing note below, and `Agreements` being verified-developers-only in the community repo. -> **Signing.** The installer is currently signed with a self-signed cert (`CN=unom`, subject == -> issuer) and ships a `.cer` users import manually. winget does not sign anything; it downloads and -> runs the same binary, so SmartScreen behaves exactly as it does for a browser download. That is a -> pre-existing condition rather than something winget introduces — but the community repo -> (`microsoft/winget-pkgs`) gates on it via its `Binary-Validation-Error` / -> `Validation-Defender-Error` checks, so a submission there needs a publicly-trusted cert (Azure -> Trusted Signing is the cheap path). A self-hosted source has no such gate. +> **Signing.** The installer is signed with **Azure Artifact Signing** (account `unomsigning`, +> profile `unom-io`) — a publicly trusted CA, so there is no `.cer` for users to import. This +> removed the blocker on submitting to the community repo (`microsoft/winget-pkgs`), whose +> `Binary-Validation-Error` / `Validation-Defender-Error` checks require a publicly trusted cert; +> the remaining upstream obstacle is `Agreements` being verified-developers-only. Note that a +> trusted cert is not an instant SmartScreen bypass: reputation still accrues per publisher over +> downloads, it just now accrues to a named identity instead of being permanently unknown. diff --git a/packaging/winget/server/README.md b/packaging/winget/server/README.md index f587661d..ce0377f8 100644 --- a/packaging/winget/server/README.md +++ b/packaging/winget/server/README.md @@ -16,12 +16,10 @@ winget upgrade unom.PunktfunkHost ## Why self-hosted rather than the community repo `microsoft/winget-pkgs` gates submissions on its `Binary-Validation-Error` / -`Validation-Defender-Error` checks, and the host installer is currently signed with a self-signed -cert (`CN=unom`). That is a pre-existing condition — winget does not sign anything, so SmartScreen -behaves identically whether the installer arrives by browser or by `winget` — but it does block that -route until a publicly trusted cert is in place. A self-hosted source has no such gate, can carry -`Agreements` (verified-developers-only upstream), and can serve channels the community repo would -never accept. +`Validation-Defender-Error` checks, which need a publicly trusted signing cert. That blocker is +gone — the host installer is now signed with Azure Artifact Signing (see `packaging/windows/README.md`) +— but a self-hosted source is still the right call: it has no such gate, can carry `Agreements` +(verified-developers-only upstream), and can serve channels the community repo would never accept. ## What it implements diff --git a/scripts/ci/provision-windows-punktfunk-extras.ps1 b/scripts/ci/provision-windows-punktfunk-extras.ps1 index 2a79c611..33541363 100644 --- a/scripts/ci/provision-windows-punktfunk-extras.ps1 +++ b/scripts/ci/provision-windows-punktfunk-extras.ps1 @@ -113,4 +113,60 @@ $env:PATH = "C:\Users\Public\ffmpeg\bin;" + $env:PATH '@ | Set-Content -Encoding UTF8 $projectEnv info "wrote $projectEnv (FFMPEG_DIR) - restart the gitea-act-runner scheduled task to pick it up" +# --- Azure Artifact Signing (formerly Trusted Signing) toolchain, for the signing step in +# windows-host.yml + windows-client.yml. Two pieces, neither of which the generic unom/infra image +# carries, and both of which fail in ways that do not name themselves: +# +# 1. The .NET 8 runtime. 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 - so on a box with +# no .NET runtime, signtool exits 3 having printed NOTHING AT ALL. Verified on .133 2026-08-14: +# the box had pwsh 7 (self-contained, brings no shared runtime) and no dotnet whatsoever. +# 2. The signing client, installed MACHINE-WIDE under C:\trusted-signing rather than into a user's +# .nuget. The act_runner daemon runs as SYSTEM, whose USERPROFILE is +# C:\Windows\System32\config\systemprofile - so a per-user install under Administrator is +# invisible to every job that actually builds. Find-AzureDlib in both pack scripts searches +# this exact path for that reason; verified by resolving it from a SYSTEM scheduled task. +# +# Both are SHA-256 pinned against version-immutable URLs (a nuget.org flat-container package and the +# dotnet builds CDN are both immutable per version), so these fail closed on tampering rather than +# every time Microsoft ships a patch release. Bump version + hash together to move either. --- +$dotnetVer = '8.0.30' +$dotnetSha = 'E40F199C6D5584AFF0554C01163C3C8D9CCF6BEC3A577E4D967E41070772A1C1' +$tscVer = '1.0.95' +$tscSha = '3BFCF1E0A3CB42AF1692F0A8ED45C15DE070C2DE86F28A59B2795D904D8A920F' + +if (Test-Path 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App') { + info "shared .NET runtime already present ($((Get-ChildItem 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App' | ForEach-Object Name) -join ', '))" +} else { + info "installing .NET $dotnetVer runtime (required by Azure.CodeSigning.Dlib.dll)" + $dn = "$env:TEMP\dotnet-runtime-$dotnetVer-win-x64.exe" + Invoke-WebRequest -Uri "https://builds.dotnet.microsoft.com/dotnet/Runtime/$dotnetVer/dotnet-runtime-$dotnetVer-win-x64.exe" -OutFile $dn -UseBasicParsing + $got = (Get-FileHash $dn -Algorithm SHA256).Hash + if ($got -ne $dotnetSha) { Remove-Item $dn -Force; throw ".NET runtime download hash mismatch (got $got, pinned $dotnetSha)." } + # -Wait is load-bearing: the bundle is a GUI PE that returns immediately when invoked with &, + # leaving $LASTEXITCODE unset and racing any completion check against the install. + $p = Start-Process -FilePath $dn -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru + Remove-Item $dn -Force -ErrorAction SilentlyContinue + if ($p.ExitCode -ne 0) { throw ".NET runtime installer exited $($p.ExitCode)." } + if (-not (Test-Path 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App')) { throw ".NET runtime installer reported success but installed no shared runtime." } +} + +$tscDir = "C:\trusted-signing\microsoft.trusted.signing.client\$tscVer" +if (Test-Path (Join-Path $tscDir 'bin\x64\Azure.CodeSigning.Dlib.dll')) { + info "Trusted Signing client $tscVer already present at $tscDir" +} else { + info "installing Microsoft.Trusted.Signing.Client $tscVer (machine-wide, for SYSTEM)" + $nupkg = "$env:TEMP\microsoft.trusted.signing.client.$tscVer.nupkg" + Invoke-WebRequest -Uri "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$tscVer/microsoft.trusted.signing.client.$tscVer.nupkg" -OutFile $nupkg -UseBasicParsing + $got = (Get-FileHash $nupkg -Algorithm SHA256).Hash + if ($got -ne $tscSha) { Remove-Item $nupkg -Force; throw "Trusted Signing client download hash mismatch (got $got, pinned $tscSha)." } + if (Test-Path $tscDir) { Remove-Item -Recurse -Force $tscDir } + New-Item -ItemType Directory -Force -Path $tscDir | Out-Null + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($nupkg, $tscDir) + Remove-Item $nupkg -Force -ErrorAction SilentlyContinue + Get-ChildItem -Path $tscDir -Recurse -File | Unblock-File -ErrorAction SilentlyContinue + if (-not (Test-Path (Join-Path $tscDir 'bin\x64\Azure.CodeSigning.Dlib.dll'))) { throw "extracted $tscVer but bin\x64\Azure.CodeSigning.Dlib.dll is absent." } +} + info "punktfunk extras provisioned OK."