Files
punktfunk/packaging/windows/make-driver-cert.ps1
T
enricobuehlerandClaude Opus 5 30710b689d 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>
2026-07-29 12:12:21 +02:00

166 lines
9.0 KiB
PowerShell

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