fix(packaging/windows): a release must not sign itself with a throwaway cert

Both pack scripts fell back to a fresh `New-SelfSignedCertificate` whenever
MSIX_CERT_PFX_B64 was absent, and said nothing about it beyond one Write-Host.
That fallback is right for canary and dev builds. On a tag it is not: an ephemeral
cert is regenerated per build, so nobody can pin it, and a release signed with one
is indistinguishable from a release signed by whoever else got to the artifact.
A secret that got renamed, rotated away, or simply wasn't inherited by a new
workflow would have downgraded a real release silently and shipped it.

pack-msix.ps1 and pack-host-installer.ps1 now resolve -RequireSignedCert (default
'auto' = true iff GITHUB_REF is refs/tags/v*) and throw instead of falling back;
pack-host-installer.ps1 also refuses -NoSign on the same condition. Reading
GITHUB_REF inside the scripts rather than taking a workflow flag means a future
packaging workflow inherits the guard instead of having to remember it.

No effect on today's releases: MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD are both
set as repo secrets, so the supplied-cert branch is the one tag builds take. This
only closes the trapdoor underneath it.

Verified on the windows-amd64 runner: both files parse, and the guard resolves
true for refs/tags/v0.21.0, false for refs/heads/main and for an unset ref, with
'true'/'false' overriding as intended.

Not touched: the pf-vdisplay / gamepad driver builds use the same pattern with
DRIVER_CERT_PFX_B64, which has no secret behind it — those need attestation
signing (a Partner Center action), and a guard there would just fail every tag.

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 06ffca985d
commit 5db93c1156
4 changed files with 50 additions and 9 deletions
+3 -1
View File
@@ -22,7 +22,9 @@
# #
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them # Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
# an ephemeral self-signed cert is generated and its public .cer published next to the installer # an ephemeral self-signed cert is generated and its public .cer published next to the installer
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1. # (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag
# the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert.
# See packaging/windows/pack-host-installer.ps1.
# #
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer. # GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at # - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
+8 -5
View File
@@ -21,11 +21,14 @@
# Published to the generic registry + the `canary/` alias. # Published to the generic registry + the `canary/` alias.
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix). # Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
# #
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets # Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
# are set (a real or shared code-signing .pfx whose subject DN == Publisher), the package is signed # Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
# with them. Otherwise an ephemeral self-signed cert is generated and its public .cer is published # package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
# next to the .msix (users import it to Trusted People before install). Drop in a real cert later # .cer is published next to the .msix (users import it to Trusted People before install).
# with no workflow change — just add the secrets (+ pass -Publisher if its subject differs). #
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
name: windows-msix name: windows-msix
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels # One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never # it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
+17 -1
View File
@@ -14,6 +14,8 @@
in-process. The package installs only where that cert is trusted, so the matching public in-process. The package installs only where that cert is trusted, so the matching public
.cer is exported next to the .msix for the user to import (Trusted People) before install. .cer is exported next to the .msix for the user to import (Trusted People) before install.
Swap in a real cert later with zero manifest changes — just pass -PfxBase64/-Publisher. Swap in a real cert later with zero manifest changes — just pass -PfxBase64/-Publisher.
This fallback is for canary/CI/dev ONLY: on a v* tag build a missing cert is a hard failure
(-RequireSignedCert), never a silent downgrade to a throwaway cert.
Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present. Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present.
@@ -33,7 +35,9 @@ param(
[string]$OutDir = (Join-Path $TargetDir 'msix'), [string]$OutDir = (Join-Path $TargetDir 'msix'),
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN [string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx [string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD [string]$PfxPassword = $env:MSIX_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'
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
@@ -138,11 +142,23 @@ $msix = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.msix"
if ($LASTEXITCODE -ne 0) { throw "makeappx pack failed ($LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "makeappx pack failed ($LASTEXITCODE)" }
# --- signing cert (supplied stable pfx OR ephemeral self-signed) --- # --- signing cert (supplied stable pfx OR ephemeral self-signed) ---
# FAIL CLOSED on a real release. The ephemeral fallback below exists so canary/CI/dev builds keep
# working without the secret, but it is a per-build throwaway cert: nobody can pin it, and a package
# signed with one is indistinguishable from a package signed by an attacker's. Silently falling back
# on a tag build would ship exactly that to users under the release's name — so on refs/tags/v* an
# absent MSIX_CERT_PFX_B64 is a build failure, not a downgrade. ('auto' resolves from GITHUB_REF, so
# a workflow can't forget to opt in; -RequireSignedCert true/false overrides for local testing.)
$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' }
else { [Convert]::ToBoolean($RequireSignedCert) }
$pfxPath = Join-Path $OutDir 'signing.pfx' $pfxPath = Join-Path $OutDir 'signing.pfx'
$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.cer" $cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.cer"
if ($PfxBase64) { if ($PfxBase64) {
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)" Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64)) [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
} elseif ($requireCert) {
throw ("release build ($env:GITHUB_REF) with no MSIX_CERT_PFX_B64 — refusing to fall back to an " +
"ephemeral self-signed cert. Restore the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD repo " +
"secrets, or pass -RequireSignedCert false if this really is a test build.")
} else { } else {
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)" Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' } if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
+22 -2
View File
@@ -5,7 +5,9 @@
.DESCRIPTION .DESCRIPTION
From a release `cargo build -p punktfunk-host --features nvenc` output (the exe), this: From a release `cargo build -p punktfunk-host --features nvenc` output (the exe), this:
1. resolves a code-signing cert (supplied stable .pfx from CI secrets OR an ephemeral self-signed 1. resolves a code-signing cert (supplied stable .pfx from CI secrets OR an ephemeral self-signed
CN=unom - same scheme as the client's pack-msix.ps1) and exports the public .cer, CN=unom - same scheme as the client's pack-msix.ps1) and exports the public .cer. The
ephemeral fallback is for canary/CI/dev ONLY: on a v* tag build a missing cert (or -NoSign)
is a hard failure, never a silent downgrade to a throwaway cert - see -RequireSignedCert,
2. signs the inner punktfunk-host.exe, 2. signs the inner punktfunk-host.exe,
3. stages the pf-vdisplay virtual-display driver bundle (unless -NoDriver), 3. stages the pf-vdisplay virtual-display driver bundle (unless -NoDriver),
4. runs ISCC to build punktfunk-host-setup-<ver>.exe, 4. runs ISCC to build punktfunk-host-setup-<ver>.exe,
@@ -31,7 +33,9 @@ param(
[string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console + runner [string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console + runner
[string]$VbCableDir = $env:VBCABLE_DIR, # official base VB-CABLE package -> bundle the virtual mic [string]$VbCableDir = $env:VBCABLE_DIR, # official base VB-CABLE package -> bundle the virtual mic
[switch]$NoDriver, # build without the bundled pf-vdisplay driver [switch]$NoDriver, # build without the bundled pf-vdisplay driver
[switch]$NoSign # skip signing (local debug) [switch]$NoSign, # skip signing (local debug)
# 'auto' (default) = required iff this is a v* tag build; 'true'/'false' to force. See below.
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto'
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
@@ -71,6 +75,17 @@ $iscc = Find-Iscc
Write-Host "ISCC: $iscc" Write-Host "ISCC: $iscc"
# --- signing cert (supplied stable pfx OR ephemeral self-signed) ----------------------------- # --- signing cert (supplied stable pfx OR ephemeral self-signed) -----------------------------
# FAIL CLOSED on a real release. The ephemeral fallback below exists so canary/CI/dev builds keep
# working without the secret, but it is a per-build throwaway cert: nobody can pin it, and an
# installer signed with one is indistinguishable from one signed by an attacker. Silently falling
# back on a tag build would ship exactly that to users under the release's name — so on refs/tags/v*
# a missing MSIX_CERT_PFX_B64 (or -NoSign) is a build failure, not a downgrade. ('auto' resolves from
# GITHUB_REF so a workflow can't forget to opt in; -RequireSignedCert true/false overrides.)
$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' }
else { [Convert]::ToBoolean($RequireSignedCert) }
if ($NoSign -and $requireCert) {
throw "release build ($env:GITHUB_REF) with -NoSign — refusing to publish an unsigned installer."
}
$pfxPath = Join-Path $OutDir 'signing.pfx' $pfxPath = Join-Path $OutDir 'signing.pfx'
$cerPath = Join-Path $OutDir "punktfunk-host-windows_${Version}.cer" $cerPath = Join-Path $OutDir "punktfunk-host-windows_${Version}.cer"
$signtool = $null $signtool = $null
@@ -81,6 +96,11 @@ if (-not $NoSign) {
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)" Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64)) [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
} }
elseif ($requireCert) {
throw ("release build ($env:GITHUB_REF) with no MSIX_CERT_PFX_B64 — refusing to fall back to " +
"an ephemeral self-signed cert. Restore the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD " +
"repo secrets, or pass -RequireSignedCert false if this really is a test build.")
}
else { else {
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)" Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' } if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }