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 '==============================================================='