From 30710b689da41e8bc1eef7c67ba4a1f06cf10cbb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 09:57:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(packaging/windows):=20make-driver-cert.ps1?= =?UTF-8?q?=20=E2=80=94=20one=20command=20for=20the=20signing=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runbook was a dozen copy-pasted lines with three separate ways to get it subtly wrong, so it is a script now. It prints the thumbprint, writes the two secret values to files (not to the terminal, so they stay out of scrollback), and never puts the private key in a certificate store — the only thing to clean up is the output folder. `-TestOnly` does a full dry run and keeps nothing. Three things the script exists to get right, all of which bit during testing: Generation uses the .NET CertificateRequest API rather than New-SelfSignedCertificate, so no key container is involved and it works over SSH. New-SelfSignedCertificate fails there with NTE_PERM 0x80090010 — a network logon has no key container. CONSUMING a .pfx still needs one, so the signtool self-test reports SKIPPED over SSH instead of failing; distinguishing "cannot test here" from "test failed" matters, because the first is a property of the session and the second is a broken key. Any other signtool error is still fatal. The extension set is explicit and matches what the drivers have actually been signed with, read off the certs sitting on the runner: KeyUsage=DigitalSignature (critical), EKU=codeSigning (non-critical), SubjectKeyIdentifier, and NO basicConstraints. Improvising here would surface as a failed driver install on a user's machine rather than as a build error, so it copies the known-good shape. .NET's PKCS#12 writer, not OpenSSL — OpenSSL 3's default AES-256/PBKDF2 produces a .pfx that Windows CryptoAPI frequently cannot read. And RandomNumberGenerator for the passphrase, not Get-Random, which is System.Random. Dry-run verified on the windows-amd64 runner: 3072-bit, 10-year, the three expected extensions, self-test correctly SKIPPED with the NTE_PERM reason. Co-Authored-By: Claude Opus 5 (1M context) --- packaging/windows/README.md | 54 ++++---- packaging/windows/make-driver-cert.ps1 | 165 +++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 packaging/windows/make-driver-cert.ps1 diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 4514ad83..70193eed 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -125,6 +125,7 @@ fresh install uses the generated random console password — read it from | `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. | | `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-gamepad` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. | | `install-vbcable.ps1` | On-target: seed VB-Audio's cert into `TrustedPublisher`, silently install the bundled VB-CABLE (`-i -h`). Run by the installer's *Install VB-CABLE virtual audio* task; idempotent + always exits 0 (non-fatal). | +| `make-driver-cert.ps1` | Generate the stable `CN=punktfunk-driver` code-signing cert (the `DRIVER_CERT_PFX_B64` / `DRIVER_CERT_PASSWORD` secrets). No key container, so it works over SSH; self-tests with signtool where it can. See *Driver signing* above. | | `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). | | `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. | | `../../scripts/windows/web-run.cmd` | The `PunktfunkWeb` task action: loads the mgmt token + login password env, runs the bundled `bun` on the Nitro server (`:47992`). | @@ -171,38 +172,35 @@ entirely — including the pile left by the per-build-cert era. > 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: +Generating it — **run `make-driver-cert.ps1` yourself** on a Windows box; it prints the thumbprint +and writes the two secret values to files, and the private key never touches a certificate store: ```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. RandomNumberGenerator, NOT Get-Random — -# Get-Random is System.Random, which is not a cryptographic RNG and has no business generating -# the passphrase on a signing key. (.Create()/.GetBytes() works on both PS 5.1 and PS 7.) -$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() -$bytes = [byte[]]::new(24); $rng.GetBytes($bytes); $pw = [Convert]::ToBase64String($bytes) -$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 **repo**-level Gitea Actions secrets on unom/punktfunk — same scope as the -# MSIX_CERT_PFX_B64 signing cert next door, and only this repo builds drivers. (RPM_GPG_PRIVATE_KEY -# is org-level because other repos publish RPMs; nothing else needs this one.) Then destroy the -# local copies — the .pfx must not linger on the machine that generated it: -Remove-Item .\driver.pfx -Force -Remove-Item "Cert:\CurrentUser\My\$($c.Thumbprint)" -Force +pwsh -File packaging\windows\make-driver-cert.ps1 -TestOnly # dry run: generates, self-tests, keeps nothing +pwsh -File packaging\windows\make-driver-cert.ps1 # the real thing ``` -Generate it on a machine you would trust with a signing key, at an **interactive** logon (physical -console or RDP). Over SSH, `New-SelfSignedCertificate` fails with `NTE_PERM 0x80090010`: a network -logon has no access to the user's key container. Avoid generating it on the CI runner — that box -executes build code, which is the one place a signing key should not be sitting. +Then add both values as **repo**-level Gitea Actions secrets on `unom/punktfunk` — same scope as +the `MSIX_CERT_PFX_B64` cert next door, and only this repo builds drivers (`RPM_GPG_PRIVATE_KEY` is +org-level because other repos publish RPMs; nothing else needs this one). Back up the `.pfx` and its +password somewhere you'd keep a signing key, then delete the output folder. + +Two details the script exists to get right, both learned the hard way: + +- It builds the cert with the .NET `CertificateRequest` API instead of `New-SelfSignedCertificate`, + so **no key container is involved** and generation works over SSH. `New-SelfSignedCertificate` + fails there with `NTE_PERM 0x80090010` — a network logon has no key container. Note that + *consuming* a `.pfx` (signtool, or loading it in .NET) still needs one, which is why the script's + signtool self-test reports SKIPPED over SSH rather than failing. The key is valid either way; run + it at a console/RDP session to exercise the self-test, or let a canary build be the proof. +- The extension set is explicit and matches what the drivers have always been signed with — + `KeyUsage=DigitalSignature` (critical), `EKU=codeSigning` (non-critical), SubjectKeyIdentifier, + and deliberately **no** basicConstraints. This is not the place to improvise: a chain-building + difference would surface as a failed driver install on a user's machine, not as a build error. + +It also avoids `Get-Random` for the .pfx passphrase (that's `System.Random`, not a cryptographic +RNG) and uses .NET's own PKCS#12 writer rather than OpenSSL, whose 3.x default AES-256/PBKDF2 +encryption produces a `.pfx` Windows CryptoAPI often cannot read. 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 diff --git a/packaging/windows/make-driver-cert.ps1 b/packaging/windows/make-driver-cert.ps1 new file mode 100644 index 00000000..7e26d44a --- /dev/null +++ b/packaging/windows/make-driver-cert.ps1 @@ -0,0 +1,165 @@ +<# +.SYNOPSIS + Generate the stable punktfunk driver code-signing certificate. + +.DESCRIPTION + Produces a self-signed code-signing cert (CN=punktfunk-driver) and writes the two values that + become the DRIVER_CERT_PFX_B64 / DRIVER_CERT_PASSWORD Gitea Actions secrets. + + Built on the .NET CertificateRequest API rather than New-SelfSignedCertificate, for two reasons: + * No key container is involved, so this works over SSH. New-SelfSignedCertificate fails with + NTE_PERM 0x80090010 on a network logon because it wants the user's key container. + * The extension set is explicit, so it matches byte-for-byte what the drivers have always been + signed with (verified against the certs on this box): KeyUsage=DigitalSignature (critical), + EKU=codeSigning (non-critical), SubjectKeyIdentifier, and NO basicConstraints. + + The key exists only in memory and in the .pfx this writes. It is never added to a certificate + store, so there is nothing to clean up afterwards except the output folder. + + Self-test: the generated .pfx is used to actually sign a scratch binary with signtool before + anything is reported, so a cert signtool cannot consume fails HERE and not six months from now + in a release build. + +.PARAMETER OutDir + Where to write the outputs. Defaults to a fresh timestamped folder under the user profile. + +.PARAMETER TestOnly + Generate, self-test, then delete everything and report. Writes no secrets. Use to verify the + script works before generating the real key. + +.EXAMPLE + pwsh -File make-driver-cert.ps1 +#> +[CmdletBinding()] +param( + [string]$OutDir, + [switch]$TestOnly +) +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false + +$subject = 'CN=punktfunk-driver' +$years = 10 + +if (-not $OutDir) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $OutDir = Join-Path $env:USERPROFILE "punktfunk-driver-cert-$stamp" +} +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +# ── 1. key + self-signed cert, in memory ────────────────────────────────────────────────────── +# RSA 3072 / SHA-256. The drivers were previously signed with 2048; nothing interoperates with this +# cert except our own installer (we are our own trust anchor), so there is no compatibility reason +# to stay at 2048 — and the signtool self-test below proves 3072 is consumable. +$rsa = [System.Security.Cryptography.RSA]::Create(3072) +$req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $subject, $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + +# KeyUsage: DigitalSignature, CRITICAL — matches the existing certs. +$req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, $true)) +# EKU: code signing (1.3.6.1.5.5.7.3.3), NON-critical — matches the existing certs. +$oids = [System.Security.Cryptography.OidCollection]::new() +$oids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3')) | Out-Null +$req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($oids, $false)) +# SubjectKeyIdentifier — matches. Deliberately NO basicConstraints: the shipping certs carry none, +# and this is the one place to not get creative, since a chain-building difference would surface as +# a failed driver install on a user's machine rather than as an error here. +$req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new($req.PublicKey, $false)) + +$now = [DateTimeOffset]::UtcNow.AddMinutes(-5) # backdate slightly: clock skew must not make it not-yet-valid +$cert = $req.CreateSelfSigned($now, $now.AddYears($years)) + +# ── 2. export ───────────────────────────────────────────────────────────────────────────────── +# RandomNumberGenerator, not Get-Random: Get-Random is System.Random and has no business generating +# the passphrase on a signing key. +$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() +$bytes = [byte[]]::new(24); $rng.GetBytes($bytes) +$pw = [Convert]::ToBase64String($bytes) + +# .NET's own PKCS#12 writer — avoids the OpenSSL 3 trap where the default AES-256/PBKDF2 encryption +# produces a .pfx that Windows CryptoAPI cannot read. +$pfxBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $pw) +$pfxPath = Join-Path $OutDir 'driver.pfx' +[IO.File]::WriteAllBytes($pfxPath, $pfxBytes) + +# ── 3. self-test: can signtool actually sign with it? ───────────────────────────────────────── +function Find-SdkTool([string]$name) { + $root = 'C:\Program Files (x86)\Windows Kits\10\bin' + Get-ChildItem -Path $root -Recurse -Filter $name -EA SilentlyContinue | + Where-Object { $_.FullName -match '\\(10\.0\.\d+\.\d+)\\x64\\' } | + Sort-Object { [version]([regex]::Match($_.FullName, '\\(10\.0\.\d+\.\d+)\\x64\\').Groups[1].Value) } | + Select-Object -Last 1 -Expand FullName +} +$signtool = Find-SdkTool 'signtool.exe' +$selftest = 'SKIPPED (no signtool on this box)' +if ($signtool) { + $scratch = Join-Path $OutDir 'selftest.exe' + Copy-Item C:\Windows\System32\notepad.exe $scratch -Force + $out = & $signtool sign /fd SHA256 /f $pfxPath /p $pw $scratch 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + # Assert the signature is present and carries our subject. /pa chain trust FAILS until the + # cert is in the machine's trust stores — expected, and not what this checks. + $v = & $signtool verify /pa /v $scratch 2>&1 | Out-String + $selftest = if ($v -match 'punktfunk-driver') { 'PASS (signtool signed; signature carries CN=punktfunk-driver)' } + else { 'PASS (signtool signed)' } + } + elseif ($out -match '0x80090010') { + # NTE_PERM. Not a bad certificate — a network logon (SSH) has no key container, so signtool + # cannot import the .pfx to sign with it. The KEY ABOVE IS STILL VALID: generating it needs + # no container, only consuming it does. Re-run at an interactive logon (console/RDP) to + # exercise this, or just let the canary CI build be the proof — the runner signs under a + # real logon, which is how the MSIX cert already works. + $selftest = 'SKIPPED (NTE_PERM 0x80090010 — no key container on this logon; run at a console/RDP session, or verify via a canary build)' + } + else { + throw "SELF-TEST FAILED: signtool could not sign with the generated .pfx (exit $LASTEXITCODE)`n$out" + } + Remove-Item $scratch -Force -EA SilentlyContinue +} + +# ── 4. report ───────────────────────────────────────────────────────────────────────────────── +if ($TestOnly) { + Remove-Item $OutDir -Recurse -Force + Write-Output '' + Write-Output "TEST ONLY — nothing kept." + Write-Output " thumbprint would have been : $($cert.Thumbprint)" + Write-Output " key size : $($cert.PublicKey.GetRSAPublicKey().KeySize) bits" + Write-Output " not after : $($cert.NotAfter.ToString('yyyy-MM-dd'))" + Write-Output " signtool self-test : $selftest" + Write-Output " extensions : $((($cert.Extensions | ForEach-Object { $_.Oid.FriendlyName }) -join ', '))" + return +} + +$b64Path = Join-Path $OutDir 'DRIVER_CERT_PFX_B64.txt' +$pwPath = Join-Path $OutDir 'DRIVER_CERT_PASSWORD.txt' +[IO.File]::WriteAllText($b64Path, [Convert]::ToBase64String($pfxBytes)) +[IO.File]::WriteAllText($pwPath, $pw) + +Write-Output '' +Write-Output '================ punktfunk driver signing cert ================' +Write-Output '' +Write-Output " THUMBPRINT (public — this is the only value to share):" +Write-Output " $($cert.Thumbprint)" +Write-Output '' +Write-Output " key size : $($cert.PublicKey.GetRSAPublicKey().KeySize) bits" +Write-Output " valid until : $($cert.NotAfter.ToString('yyyy-MM-dd'))" +Write-Output " self-test : $selftest" +Write-Output '' +Write-Output ' SECRETS — do not paste these into chat or a terminal. Open the files:' +Write-Output " DRIVER_CERT_PFX_B64 -> $b64Path" +Write-Output " DRIVER_CERT_PASSWORD -> $pwPath" +Write-Output '' +Write-Output ' Add both as REPO-level secrets at:' +Write-Output ' https://git.unom.io/unom/punktfunk/settings/actions/secrets' +Write-Output '' +Write-Output " BACK UP $pfxPath + the password somewhere you'd keep a signing key FIRST." +Write-Output ' Then remove the whole folder:' +Write-Output " Remove-Item -Recurse -Force '$OutDir'" +Write-Output '' +Write-Output '==============================================================='