feat(packaging/windows): give the drivers one publisher identity, and give the trust back
The drivers had no cryptographic identity at all. Every build minted a fresh `CN=punktfunk-driver` cert (build-pf-vdisplay.ps1, build-gamepad-drivers.ps1), and install.rs `trust_cert` adds whatever .cer it finds in the unpacked bundle to machine Root AND TrustedPublisher. So the signature vouched for nothing an attacker couldn't restage — replace the bundle, ship your own cert beside your own driver, install proceeds identically. It was ceremony to make PnP install quietly. Worse, it leaked. `trust_cert` runs once per driver (install.rs:102 and :155), so every upgrade added TWO more self-signed root CAs under the same name, and nothing ever removed them: uninstalling punktfunk left trust behind that the user had no reason to keep granting. So: both build scripts now take a stable cert via DRIVER_CERT_PFX_B64 (they already read the env var — windows-host.yml just never passed it) and fail closed on a v* tag, same rule as the host and MSIX packers. `driver uninstall` purges every `CN=punktfunk-driver` cert from both stores, and `driver install` purges before adding, so an upgrade also collects the historical pile instead of adding to it. Purge-before-add lives ONLY on the pf-vdisplay install path, not the gamepad one. The installer runs vdisplay first and gamepad second; purging in both would have the gamepad leg delete the cert the vdisplay leg just added whenever the two bundles carry different certs — which is exactly what canary's per-build fallback produces. Purging by subject rather than thumbprint is deliberate too: it is what lets one install clean up certs from builds that no longer exist anywhere, and it needs no parsing of certutil's localized output (this module exists because locale-parsed PowerShell broke the driver install on a German box). This does NOT make the driver download authenticated — a self-signed leaf is its own root, so the installer must trust it for the driver to install at all. What it buys is a fingerprint we can publish out-of-band so a substituted driver is detectable, an allowlistable publisher, continuity across releases, and no root accumulation. Attestation signing remains the real fix; documented as such. Documented the key-custody trade honestly in packaging/windows/README.md: a stable key trusted as a machine root on every install is worth stealing in a way a throwaway never was, with no revocation path. CI secret only. ⚠️ The secrets must exist before the next v* tag or the release will fail — that is the guard working, and the generation runbook is in that README. The fingerprint line there is a placeholder until the key is generated. Verified: punktfunk-host compiles clean on the windows-amd64 runner with this install.rs (exit 0, no warnings); both build scripts parse; cargo fmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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):** `<fill in after generating — see below>`
|
||||
|
||||
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
|
||||
|
||||
@@ -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' `
|
||||
|
||||
@@ -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' `
|
||||
|
||||
Reference in New Issue
Block a user