diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index 6cf48855..60ad28fa 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -352,6 +352,12 @@ jobs: env: 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. + DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }} + DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }} run: | & packaging/windows/pack-host-installer.ps1 ` -Version $env:HOST_VERSION -TargetDir C:\t\release -OutDir C:\t\out diff --git a/SECURITY.md b/SECURITY.md index 81a74d42..9f961a91 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -73,6 +73,14 @@ us beyond the download itself. - **Windows installers and MSIX packages** are Authenticode-signed; a release build that cannot reach its code-signing certificate fails to build rather than falling back to a self-signed one. Check with `Get-AuthenticodeSignature punktfunk-host-setup-1.2.3.exe`. +- **The Windows drivers** (virtual display, virtual gamepads) are signed with a stable self-signed + certificate, `CN=punktfunk-driver`, whose fingerprint is published in + [`packaging/windows/README.md`](packaging/windows/README.md). The installer has to add it to the + machine's trusted roots for a self-signed driver to install at all, so — unlike the cases above — + this signature does **not** authenticate the download: it gives the drivers a stable publisher + identity you can compare against the published fingerprint, and it is removed again on uninstall. + Verify with `Get-AuthenticodeSignature` on the installed `pf_vdisplay.dll`, or list what is + trusted with `Get-ChildItem Cert:\LocalMachine\Root | ? Subject -like '*punktfunk*'`. A checksum on its own only tells you the download wasn't corrupted in transit — it says nothing about who produced the file, since anyone able to replace an artifact can replace its checksum. diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs index b9156420..c197f5c4 100644 --- a/crates/punktfunk-host/src/windows/install.rs +++ b/crates/punktfunk-host/src/windows/install.rs @@ -74,6 +74,40 @@ fn driver_install(args: &[String]) -> Result<()> { Ok(()) } +/// The subject CN both driver-signing certs carry (`build-pf-vdisplay.ps1` / +/// `build-gamepad-drivers.ps1`). certutil matches a CertId against the subject, so this is how we +/// find our own certs again without parsing any localized output — see `purge_driver_certs`. +const DRIVER_CERT_CN: &str = "punktfunk-driver"; + +/// Remove every `CN=punktfunk-driver` cert this product ever added, from machine `Root` and +/// `TrustedPublisher`. +/// +/// Two reasons this has to exist. Uninstall used to leave the certs behind forever, so removing +/// punktfunk left a trusted root CA on the machine — trust we asked for and then never gave back. +/// And before the signing cert was stabilised, every BUILD minted a fresh throwaway cert, so each +/// upgrade added two more roots under the same name; a box that has been upgraded a dozen times is +/// carrying two dozen of them. Purging by subject rather than by thumbprint is what lets one +/// install clean up the whole historical pile. +/// +/// Deleting the root does NOT unload an already-installed driver: PnP validates the signature when +/// the package is staged into the driver store, not on every load. So a purge is safe to run before +/// re-adding the current cert. +/// +/// Best-effort and silent, like everything else here. `certutil -delstore` deletes one match per +/// call and fails once nothing matches, so loop until it stops succeeding — bounded, because a +/// pathological store must not turn an uninstall into an infinite loop. +fn purge_driver_certs() { + for store in ["Root", "TrustedPublisher"] { + let mut removed = 0; + while removed < 64 && run_quiet("certutil", &["-delstore", store, DRIVER_CERT_CN]) { + removed += 1; + } + if removed > 0 { + println!("removed {removed} stale '{DRIVER_CERT_CN}' cert(s) from {store}"); + } + } +} + /// Trust the bundled self-signed driver cert: machine `Root` (so the chain validates) + `TrustedPublisher` /// (so PnP installs without a prompt). fn trust_cert(dir: &Path) { @@ -99,6 +133,12 @@ fn install_pf_vdisplay(dir: &Path) -> Result<()> { if !inf.exists() { bail!("no pf_vdisplay.inf in {}", dir.display()); } + // Sweep the old certs before adding the current one. Deliberately only on THIS path and not in + // `install_gamepad`: the installer runs pf-vdisplay first and gamepad second, so one purge here + // clears the pile and both trust_cert calls then add on top. Purging in both would have the + // gamepad leg delete the cert the vdisplay leg just installed whenever the two bundles carry + // different certs — which is exactly what a canary build's per-build fallback certs are. + purge_driver_certs(); trust_cert(dir); // Create the ROOT device node only if absent (a blind re-create spawns a phantom duplicate, and the // host binds interface index 0). ALWAYS nefconc (a clean ROOT\DISPLAY node), NEVER devgen (which makes @@ -213,6 +253,11 @@ fn driver_uninstall(args: &[String]) -> Result<()> { // Same best-effort contract as install: never abort the (un)installer over a driver. eprintln!("warning: {what} driver uninstall: {e:#}"); } + // Give back the trust we asked for. Here in the dispatcher rather than in the two uninstall + // bodies so it runs exactly once per invocation, and idempotently when the installer calls both + // legs back to back. Uninstalling punktfunk must not leave a trusted root CA behind — and this + // also collects the historical pile from the era when every build signed with a new cert. + purge_driver_certs(); Ok(()) } diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 0f3c39f8..84e8bd41 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -144,6 +144,60 @@ 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. +## Driver signing (`DRIVER_CERT_PFX_B64`) + +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 +is a **hard failure** (`-RequireSignedCert`, default `auto` off `GITHUB_REF`); canary and local +builds still fall back to a per-build throwaway. + +**Current fingerprint (SHA-1 thumbprint):** `` + +Why stable matters here. The installer trusts the `.cer` that ships in the bundle +(`certutil -addstore -f Root` + `TrustedPublisher`, `crates/punktfunk-host/src/windows/install.rs`), +which is unavoidable for a self-signed cert — a self-signed leaf is its own root, so the chain only +validates if the root is present. That means the signature does **not** authenticate the download: +anyone who can alter the bundle can put their own cert next to their own driver. What a stable cert +buys is everything downstream of that: one anchor imported once instead of two more roots per +upgrade, a fingerprint we can publish out-of-band so a substituted driver is *detectable*, a +publisher an admin can allowlist, and continuity across releases. `driver install` purges stale +`CN=punktfunk-driver` certs before adding the current one, and `driver uninstall` removes them +entirely — including the pile left by the per-build-cert era. + +> ⚠️ **The private key is now worth stealing.** It is trusted as a machine **root** on every +> punktfunk box, with code-signing EKU and no practical revocation path (nobody removes a stale +> root, and self-signed roots aren't in any CRL users honour). Keep it in the CI secret and nowhere +> else — not on a dev laptop. This is the trade for stability, and the reason attestation signing +> (which chains to Microsoft and needs no root import at all) remains the real fix. + +Generating it — **run this yourself**, on a trusted Windows box, elevated: + +```powershell +# 1. Create the cert. 10-year lifetime: an expired driver cert breaks INSTALLS on new machines, and +# rotating means every user picks up a new root anyway, so churn buys nothing here. +$c = New-SelfSignedCertificate -Type CodeSigningCert -Subject 'CN=punktfunk-driver' ` + -CertStoreLocation Cert:\CurrentUser\My -KeyExportPolicy Exportable ` + -NotAfter (Get-Date).AddYears(10) +$c.Thumbprint # <- publish this: paste into the "Current fingerprint" line above + SECURITY.md + +# 2. Export it, protected by a strong random password. +$pw = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 32 | % { [char]$_ }) +$sec = ConvertTo-SecureString $pw -AsPlainText -Force +Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($c.Thumbprint)" -FilePath .\driver.pfx -Password $sec | Out-Null +[Convert]::ToBase64String([IO.File]::ReadAllBytes('.\driver.pfx')) | Set-Clipboard # -> DRIVER_CERT_PFX_B64 +$pw # -> DRIVER_CERT_PASSWORD + +# 3. Add BOTH as Gitea Actions secrets (org level on `unom`, like RPM_GPG_PRIVATE_KEY, so any repo +# that builds drivers inherits them), then destroy the local copies: +Remove-Item .\driver.pfx -Force +Remove-Item "Cert:\CurrentUser\My\$($c.Thumbprint)" -Force +``` + +Keep an offline backup of the .pfx + password somewhere you'd keep a signing key. Losing it means +the next release ships a cert nobody has trusted before, and every user's installer adds a second +root — recoverable, but only by re-running the install. + ## Dev iteration on the test box (driver) Two helpers wrap the painful manual steps of iterating on the pf-vdisplay driver against a live host diff --git a/packaging/windows/build-gamepad-drivers.ps1 b/packaging/windows/build-gamepad-drivers.ps1 index 4e8c22f6..f9bf7660 100644 --- a/packaging/windows/build-gamepad-drivers.ps1 +++ b/packaging/windows/build-gamepad-drivers.ps1 @@ -27,6 +27,8 @@ param( [string]$DriverVer, [string]$CertPfxB64 = $env:DRIVER_CERT_PFX_B64, [string]$CertPassword = $env:DRIVER_CERT_PASSWORD, + # 'auto' (default) = required iff this is a v* tag build; 'true'/'false' to force. See below. + [ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto', [switch]$SkipBuild ) $ErrorActionPreference = 'Stop' @@ -86,6 +88,13 @@ foreach ($t in @($signtool, $stampinf, $inf2cat)) { } # --- 3. signing cert (supplied stable pfx OR fresh self-signed; shared by both drivers) ------- +# FAIL CLOSED on a real release, same rule as the host/MSIX pack scripts. The fallback below mints +# a cert per BUILD, and the installer trusts whatever .cer ships in the bundle — so the signature +# proves nothing about origin, and each upgrade adds another self-signed root CA to the user's +# machine under the same name. That is survivable for canary and dev builds; shipping it in a +# release is not. ('auto' resolves from GITHUB_REF so a new workflow inherits the guard.) +$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' } + else { [Convert]::ToBoolean($RequireSignedCert) } $cleanupCert = $null if ($CertPfxB64) { Write-Host '==> signing with supplied driver cert (DRIVER_CERT_PFX_B64)' @@ -95,6 +104,11 @@ if ($CertPfxB64) { $signArgs = @('/f', $pfx); if ($CertPassword) { $signArgs += @('/p', $CertPassword) } $pubForCer = if ($sec) { Get-PfxCertificate -FilePath $pfx -Password $sec } else { Get-PfxCertificate -FilePath $pfx } } +elseif ($requireCert) { + throw ("release build ($env:GITHUB_REF) with no DRIVER_CERT_PFX_B64 — refusing to sign drivers " + + "with a per-build throwaway cert. Set the DRIVER_CERT_PFX_B64 / DRIVER_CERT_PASSWORD " + + "secrets (packaging/windows/README.md), or pass -RequireSignedCert false for a test build.") +} else { Write-Host '==> no DRIVER_CERT_PFX_B64 -> generating a fresh self-signed driver cert (the installer trusts the bundled .cer at install time)' $cleanupCert = New-SelfSignedCertificate -Type CodeSigningCert -Subject 'CN=punktfunk-driver' ` diff --git a/packaging/windows/build-pf-vdisplay.ps1 b/packaging/windows/build-pf-vdisplay.ps1 index 12dfa227..47433990 100644 --- a/packaging/windows/build-pf-vdisplay.ps1 +++ b/packaging/windows/build-pf-vdisplay.ps1 @@ -29,6 +29,8 @@ param( [string]$DriverVer, # default: 9.9.MMdd.HHmm (strictly-increasing) [string]$CertPfxB64 = $env:DRIVER_CERT_PFX_B64, # optional stable driver-signing cert (CI secret) [string]$CertPassword = $env:DRIVER_CERT_PASSWORD, + # 'auto' (default) = required iff this is a v* tag build; 'true'/'false' to force. See below. + [ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto', [switch]$SkipBuild # reuse an existing target\...\release\pf_vdisplay.dll ) $ErrorActionPreference = 'Stop' @@ -83,6 +85,13 @@ foreach ($t in @($signtool, $stampinf, $inf2cat)) { } # --- 3. signing cert (supplied stable pfx OR fresh self-signed) ------------------------------- +# FAIL CLOSED on a real release, same rule as the host/MSIX pack scripts. The fallback below mints +# a cert per BUILD, and the installer trusts whatever .cer ships in the bundle — so the signature +# proves nothing about origin, and each upgrade adds another self-signed root CA to the user's +# machine under the same name. That is survivable for canary and dev builds; shipping it in a +# release is not. ('auto' resolves from GITHUB_REF so a new workflow inherits the guard.) +$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' } + else { [Convert]::ToBoolean($RequireSignedCert) } $cleanupCert = $null if ($CertPfxB64) { Write-Host '==> signing with supplied driver cert (DRIVER_CERT_PFX_B64)' @@ -92,6 +101,11 @@ if ($CertPfxB64) { $signArgs = @('/f', $pfx); if ($CertPassword) { $signArgs += @('/p', $CertPassword) } $pubForCer = if ($sec) { Get-PfxCertificate -FilePath $pfx -Password $sec } else { Get-PfxCertificate -FilePath $pfx } } +elseif ($requireCert) { + throw ("release build ($env:GITHUB_REF) with no DRIVER_CERT_PFX_B64 — refusing to sign drivers " + + "with a per-build throwaway cert. Set the DRIVER_CERT_PFX_B64 / DRIVER_CERT_PASSWORD " + + "secrets (packaging/windows/README.md), or pass -RequireSignedCert false for a test build.") +} else { Write-Host '==> no DRIVER_CERT_PFX_B64 -> generating a fresh self-signed driver cert (the installer trusts the bundled .cer at install time)' $cleanupCert = New-SelfSignedCertificate -Type CodeSigningCert -Subject 'CN=punktfunk-driver' `