feat(packaging/windows): make-driver-cert.ps1 — one command for the signing key

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:12:21 +02:00
co-authored by Claude Opus 5
parent 622afc2c32
commit 30710b689d
2 changed files with 191 additions and 28 deletions
+26 -28
View File
@@ -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