Sign Windows releases with Azure Artifact Signing — a 3-day leaf makes timestamping mandatory #228
@@ -257,6 +257,19 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
|
||||
# when all three are set. Not secret: an account/profile name and a regional endpoint,
|
||||
# inert without the credentials below. The profile's verified subject is also the MSIX
|
||||
# manifest Publisher; pack-msix.ps1 reads the signature back and fails on a mismatch.
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
|
||||
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
# Legacy self-signed path, kept as the fallback for builds without Azure access.
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
|
||||
@@ -20,12 +20,18 @@
|
||||
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
|
||||
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
|
||||
#
|
||||
# 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
|
||||
# (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag
|
||||
# Signing goes through Azure Artifact Signing (account `unomsigning`, profile `unom-io`) — a publicly
|
||||
# trusted CA, so there is no .cer for users to import and no SmartScreen "unknown publisher" prompt.
|
||||
# It falls back to the old MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD self-signed cert, and then to an
|
||||
# ephemeral one, for builds without Azure access. Those fallbacks are 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.
|
||||
#
|
||||
# The bundled DRIVERS are NOT signed by Azure — they keep their own DRIVER_CERT_* cert and are still
|
||||
# trusted by planting that cert in the machine Root store at install time. Independent by design:
|
||||
# Windows checks the installer's signature via SmartScreen/UAC and driver catalogs via PnP, and never
|
||||
# requires a common signer. See packaging/windows/README.md for why that root-plant is still there.
|
||||
#
|
||||
# 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
|
||||
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
|
||||
@@ -415,12 +421,26 @@ jobs:
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
env:
|
||||
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
|
||||
# when all three of these are set. Not secret: an account/profile name and a regional
|
||||
# endpoint, all inert without the credentials below, so they live here where a reviewer
|
||||
# can see which profile a release was signed by.
|
||||
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
|
||||
AZURE_CODESIGNING_ACCOUNT: unomsigning
|
||||
AZURE_CODESIGNING_PROFILE: unom-io
|
||||
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
|
||||
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
# Legacy self-signed path, kept as the fallback for builds without Azure access.
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
# The DRIVER cert is separate from the host/MSIX one and reaches the two driver build
|
||||
# scripts through the environment (pack-host-installer.ps1 invokes them, they read
|
||||
# $env:DRIVER_CERT_PFX_B64 themselves). Without it they sign with a per-build throwaway,
|
||||
# which the installer then trusts as a machine root — see packaging/windows/README.md.
|
||||
# NOT moved to Azure: driver catalogs are a separate track, see that README.
|
||||
DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }}
|
||||
DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }}
|
||||
run: |
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
MSIX package manifest for the punktfunk Windows client (WinUI 3 via windows-reactor).
|
||||
|
||||
This is a TEMPLATE: packaging/pack-msix.ps1 substitutes {VERSION} (4-part numeric, e.g.
|
||||
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — default
|
||||
`CN=unom` for the self-signed CI cert; a real code-signing cert just passes its own subject).
|
||||
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — the default is
|
||||
the verified subject of the Azure `unom-io` certificate profile; the self-signed fallback mints
|
||||
a throwaway cert with that same subject so canary and release share a package identity).
|
||||
|
||||
Package identity is Name + Publisher, so changing {PUBLISHER} makes this a DIFFERENT package:
|
||||
installs of the older publisher cannot be upgraded in place and must be uninstalled first. That
|
||||
is a user-visible migration, not a packaging detail — mention it in the release notes. pack-msix.ps1
|
||||
reads the signature back off the packed .msix and fails the build if the two ever drift.
|
||||
|
||||
Why this packages cleanly even though the app was built "unpackaged": windows-reactor calls
|
||||
MddBootstrapInitialize2 with OnPackageIdentity_NOOP (crates/libs/reactor/src/app.rs), so under
|
||||
|
||||
@@ -56,34 +56,45 @@ MSIX requires a strictly 4-part numeric version. The workflow computes:
|
||||
|
||||
## Signing & install
|
||||
|
||||
CI signs every build with a **stable self-signed code-signing cert** (`CN=unom`, SHA-1
|
||||
`CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`, valid to 2036). Its public half is checked in as
|
||||
[`punktfunk-codesign.cer`](punktfunk-codesign.cer); the private `.pfx` + password live in the
|
||||
`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` Actions secrets. Because it's the *same* cert every build,
|
||||
trusting it is **one-time, per machine** — once imported, every future build and in-place upgrade is
|
||||
trusted with no further prompt:
|
||||
CI signs every build with **Azure Artifact Signing** (formerly Trusted Signing) — account
|
||||
`unomsigning`, certificate profile `unom-io`, endpoint `https://neu.codesigning.azure.net/`. That
|
||||
chain is publicly trusted, so **there is nothing to import**:
|
||||
|
||||
```powershell
|
||||
# once per machine (elevated): trust the publisher
|
||||
Import-Certificate -FilePath .\punktfunk-codesign.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople
|
||||
# then install the package for your CPU (and re-run for each upgrade — no re-trust needed)
|
||||
# install the package for your CPU (and re-run for each upgrade)
|
||||
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_x64.msix # Intel/AMD
|
||||
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_arm64.msix # ARM64 (Snapdragon, etc.)
|
||||
```
|
||||
|
||||
The matching `.cer` is also published next to each `.msix` in the registry, so it's always at hand.
|
||||
|
||||
The MSIX declares a dependency on the Windows App SDK 2.x runtime; install
|
||||
[the App SDK runtime](https://aka.ms/windowsappsdk) if `Add-AppxPackage` reports a missing
|
||||
`Microsoft.WindowsAppRuntime.2` framework.
|
||||
|
||||
`pack-msix.ps1` signing precedence: it uses the **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** secrets
|
||||
when present (the stable cert above), else generates an *ephemeral* self-signed cert (forks / local
|
||||
builds without the secrets). Either way it exports the signing cert's public `.cer` for the import.
|
||||
**To move to a publicly-trusted (no-import) cert** — Azure Artifact Signing or a public OV cert —
|
||||
replace the two secrets with the new `.pfx`; the cert's subject DN must equal the manifest
|
||||
`Publisher`, so pass a matching `-Publisher` (it's stamped into the package `Identity`, and changing
|
||||
it changes the package identity → a one-time reinstall).
|
||||
### How signing resolves
|
||||
|
||||
`pack-msix.ps1` picks a backend in this order:
|
||||
|
||||
1. **Azure Artifact Signing** when `AZURE_CODESIGNING_ENDPOINT` / `_ACCOUNT` / `_PROFILE` are all
|
||||
set (the workflow sets them; they aren't secret). Credentials come from `AZURE_TENANT_ID` /
|
||||
`AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` — the `punktfunk-ci-signing` service principal, which
|
||||
holds only the *Artifact Signing Certificate Profile Signer* role scoped to the `unom-io` profile.
|
||||
Keys are HSM-backed and never leave Azure, so there is no `.pfx` and no `.cer` is emitted.
|
||||
2. **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** — the older stable self-signed cert (`CN=unom`,
|
||||
public half checked in as [`punktfunk-codesign.cer`](punktfunk-codesign.cer)), kept as a fallback.
|
||||
3. An **ephemeral** self-signed cert (forks / local builds with no secrets at all).
|
||||
|
||||
Modes 2 and 3 still export a `.cer` to import into `Cert:\LocalMachine\TrustedPeople` first. On a
|
||||
`v*` tag, a build with no real signing backend **fails closed** rather than shipping a throwaway.
|
||||
|
||||
Two things about Azure mode that are easy to get wrong:
|
||||
|
||||
- **Timestamping is mandatory, not best-effort.** Azure mints a leaf cert per request that expires in
|
||||
about three days. An untimestamped signature therefore stops verifying within days of release, so
|
||||
the script refuses to retry without one (modes 2 and 3 keep the old best-effort retry).
|
||||
- **The manifest `Publisher` must equal the signer's subject exactly**, because MSIX package identity
|
||||
is Name + Publisher. The default `-Publisher` is the `unom-io` profile's verified subject; after
|
||||
signing, the script reads the signature back off the `.msix` and fails the build on any drift.
|
||||
Changing it makes a *different* package — existing installs must be uninstalled, not upgraded.
|
||||
|
||||
## Building locally
|
||||
|
||||
|
||||
@@ -13,15 +13,22 @@
|
||||
packaging/windows/pack-host-installer.ps1 still ships them for its amf-qsv encode path.
|
||||
|
||||
Signing cert precedence:
|
||||
0. Azure Artifact Signing (formerly Trusted Signing) when AZURE_CODESIGNING_ENDPOINT/_ACCOUNT/
|
||||
_PROFILE are all set. HSM-backed, so there is no .pfx and nothing to export: the chain is
|
||||
publicly trusted, so no .cer is produced and MSIX_CER_PATH stays unset.
|
||||
1. -PfxBase64 / -PfxPassword (a real or shared code-signing cert, e.g. from CI secrets) — the
|
||||
cert's subject DN MUST match -Publisher (which is stamped into the manifest Identity).
|
||||
2. otherwise an EPHEMERAL self-signed code-signing cert with subject = -Publisher is generated
|
||||
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.
|
||||
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.
|
||||
|
||||
WHICHEVER mode runs, the signed .msix is read back and its signer subject compared to -Publisher;
|
||||
a mismatch fails the build. MSIX package identity is Name + Publisher, so a publisher that does
|
||||
not match the signer is not a cosmetic problem — Add-AppxPackage rejects the package outright,
|
||||
and it would only be discovered by a user trying to install the release.
|
||||
|
||||
Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present.
|
||||
|
||||
.EXAMPLE
|
||||
@@ -36,9 +43,21 @@ param(
|
||||
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
|
||||
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64', # package ProcessorArchitecture + artifact suffix
|
||||
[string]$OutDir = (Join-Path $TargetDir 'msix'),
|
||||
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
|
||||
# MUST equal the signing cert subject DN — this is the verified subject the Azure 'unom-io'
|
||||
# certificate profile issues. The 'ü' is written as an escape, not a literal: this file is UTF-8
|
||||
# with no BOM, and read by anything other than pwsh 7 a literal would silently mojibake into a
|
||||
# publisher that no longer matches the signer, which surfaces only as an Add-AppxPackage refusal
|
||||
# on a user's machine. Verified against the real signer after signing below.
|
||||
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx
|
||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||
# Azure Artifact Signing. All three select it, ahead of any .pfx. Credentials arrive through the
|
||||
# environment via DefaultAzureCredential (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET)
|
||||
# rather than as arguments, so they cannot leak into a process listing or a transcript.
|
||||
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT, # e.g. https://neu.codesigning.azure.net/
|
||||
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT, # signing account name
|
||||
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE, # certificate profile name
|
||||
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB, # path to Azure.CodeSigning.Dlib.dll
|
||||
# 'auto' (default) = required iff this is a v* tag build; 'true'/'false' to force. See below.
|
||||
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto'
|
||||
)
|
||||
@@ -64,6 +83,28 @@ function Find-SdkTool([string]$name) {
|
||||
if (-not $hit) { throw "$name not found under $root — install the Windows 10/11 SDK." }
|
||||
$hit.FullName
|
||||
}
|
||||
# Azure.CodeSigning.Dlib.dll ships in the Microsoft.Trusted.Signing.Client NuGet package, which has
|
||||
# no installer and no fixed location — hence an explicit override first, then the paths the runner
|
||||
# setup uses (packaging/windows/README.md). Newest wins, so a package update needs no edit here.
|
||||
function Find-AzureDlib([string]$Explicit) {
|
||||
if ($Explicit) {
|
||||
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
|
||||
return (Resolve-Path $Explicit).Path
|
||||
}
|
||||
$roots = @(
|
||||
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
|
||||
'C:\trusted-signing\microsoft.trusted.signing.client'
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||||
if (-not $hit) {
|
||||
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
|
||||
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
|
||||
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
|
||||
}
|
||||
$hit.FullName
|
||||
}
|
||||
$makeappx = Find-SdkTool 'makeappx.exe'
|
||||
$signtool = Find-SdkTool 'signtool.exe'
|
||||
Write-Host "makeappx: $makeappx"
|
||||
@@ -159,13 +200,34 @@ $requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/
|
||||
else { [Convert]::ToBoolean($RequireSignedCert) }
|
||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||
$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.cer"
|
||||
if ($PfxBase64) {
|
||||
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
|
||||
$signMode = 'selfsigned'
|
||||
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
|
||||
$signMode = 'azure'
|
||||
$AzureDlib = Find-AzureDlib $AzureDlib
|
||||
# signtool takes the account/profile from this file (/dmdf), not the command line.
|
||||
@{
|
||||
Endpoint = $AzureEndpoint
|
||||
CodeSigningAccountName = $AzureAccount
|
||||
CertificateProfileName = $AzureProfile
|
||||
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
|
||||
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
|
||||
Write-Host " dlib: $AzureDlib"
|
||||
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
|
||||
if (-not [Environment]::GetEnvironmentVariable($v)) {
|
||||
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
|
||||
"DefaultAzureCredential; without the service-principal trio it falls through to an " +
|
||||
"interactive login that cannot complete on a runner and hangs the build.")
|
||||
}
|
||||
}
|
||||
} elseif ($PfxBase64) {
|
||||
$signMode = 'pfx'
|
||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||
[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.")
|
||||
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 — " +
|
||||
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
|
||||
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
|
||||
} else {
|
||||
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
|
||||
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
|
||||
@@ -178,35 +240,80 @@ if ($PfxBase64) {
|
||||
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
|
||||
}
|
||||
|
||||
# Always export the public .cer from the pfx. For a self-signed / private-trust cert it's the file
|
||||
# users import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
|
||||
# import is a one-time, per-machine step that keeps working across upgrades. For a public-CA cert
|
||||
# it's just an unused extra (harmless). The manifest Publisher must equal the cert's subject DN.
|
||||
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
|
||||
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
|
||||
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
|
||||
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
|
||||
if ($pubCert.Subject -ne $Publisher) {
|
||||
Write-Warning "cert subject '$($pubCert.Subject)' != manifest Publisher '$Publisher' — Add-AppxPackage will reject the mismatch. Pass -Publisher '$($pubCert.Subject)'."
|
||||
# Export the public .cer from the pfx. For a self-signed / private-trust cert it's the file users
|
||||
# import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
|
||||
# import is a one-time, per-machine step that keeps working across upgrades. Azure signing is
|
||||
# HSM-backed: there is no pfx to read and its chain is publicly trusted, so no .cer is produced.
|
||||
if ($signMode -ne 'azure') {
|
||||
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
|
||||
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
|
||||
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
|
||||
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
|
||||
}
|
||||
|
||||
# --- sign (timestamp best-effort) ---
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $msix))
|
||||
# --- sign ---
|
||||
# The timestamp is best-effort for a .pfx whose cert outlives the release, but MANDATORY under Azure
|
||||
# signing: those leaf certs are minted per request and expire in ~3 days, so an untimestamped
|
||||
# signature stops verifying within days of shipping. Retrying without one there would produce a
|
||||
# package that installs on the runner and fails for every user that weekend — so the fallback is
|
||||
# gated on the mode rather than applied blindly.
|
||||
if ($signMode -eq 'azure') {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
|
||||
$ts = 'http://timestamp.acs.microsoft.com'
|
||||
} else {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
$ts = 'http://timestamp.digicert.com'
|
||||
}
|
||||
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $msix))
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($signMode -eq 'azure') {
|
||||
throw ("timestamped sign failed ($LASTEXITCODE) — NOT retrying without a timestamp. An Azure " +
|
||||
"signing cert is valid for ~3 days; an untimestamped signature would go untrusted " +
|
||||
"within days of release.")
|
||||
}
|
||||
Write-Warning "timestamped sign failed — retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($msix))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed ($LASTEXITCODE)" }
|
||||
}
|
||||
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Read the signature back off the packed .msix and hold it against the manifest Publisher. MSIX
|
||||
# package identity is Name + Publisher, so a publisher that doesn't match the signer isn't cosmetic:
|
||||
# Add-AppxPackage refuses the package outright. Checking the ACTUAL signer (rather than a pfx we
|
||||
# happen to hold) is the only form of this check that works in every signing mode, and failing the
|
||||
# build here is the difference between a red pipeline and a release nobody can install.
|
||||
# Deliberately asymmetric: a subject we CAN read and that DISAGREES is a hard failure, but a subject
|
||||
# we cannot read at all is only a warning. Get-AuthenticodeSignature's support for the .msix/.appx
|
||||
# subject interface varies by Windows version, and signtool has already reported success by this
|
||||
# point — turning "the check could not run" into a build break would trade a real defect we catch for
|
||||
# an imaginary one we invent.
|
||||
$signerSubject = $null
|
||||
try { $signerSubject = (Get-AuthenticodeSignature $msix).SignerCertificate.Subject } catch { }
|
||||
if (-not $signerSubject) {
|
||||
Write-Warning ("could not read a signer subject back from $msix, so Publisher/signer agreement is " +
|
||||
"UNVERIFIED on this box. If the package is rejected at Add-AppxPackage time, compare " +
|
||||
"`signtool verify /pa /v` against the manifest Publisher '$Publisher' by hand.")
|
||||
} elseif ($signerSubject -ne $Publisher) {
|
||||
throw ("signer subject does not match the manifest Publisher, so this package cannot install:`n" +
|
||||
" signer : '$signerSubject'`n" +
|
||||
" Publisher : '$Publisher'`n" +
|
||||
"Pass -Publisher '$signerSubject' (or fix the certificate profile) and repack.")
|
||||
} else {
|
||||
Write-Host "verified signer subject matches manifest Publisher: $signerSubject"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==> MSIX: $msix"
|
||||
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
|
||||
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
|
||||
if ($signMode -eq 'azure') {
|
||||
Write-Host "==> signed by a publicly trusted CA — nothing for users to import."
|
||||
} else {
|
||||
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
|
||||
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
|
||||
}
|
||||
# emit paths for the workflow to publish (only under CI, where GITHUB_ENV is set)
|
||||
if ($env:GITHUB_ENV) {
|
||||
"MSIX_PATH=$msix" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
if ($signMode -ne 'azure') { "MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
|
||||
}
|
||||
|
||||
@@ -7,12 +7,20 @@
|
||||
//! 1. **SHA-256 == the signed manifest's** — the primary integrity gate (the manifest is the
|
||||
//! Ed25519-verified document; this check makes the downloaded bytes those exact bytes).
|
||||
//! 2. **Authenticode**: the embedded signature must be cryptographically valid, tolerating
|
||||
//! `CERT_E_UNTRUSTEDROOT` while the shipping cert is self-signed (`CN=unom`); when the
|
||||
//! `CERT_E_UNTRUSTEDROOT` (canary and local builds still sign with a self-signed cert, and
|
||||
//! releases moved to Azure Artifact Signing without needing this to tighten); when the
|
||||
//! manifest carries leaf pins, the signing leaf's SHA-256 must match one. The leaf is taken
|
||||
//! from the SAME `WinVerifyTrust` state (`WTHelperGetProvSignerFromChain`), never a second
|
||||
//! parse — no verify-vs-inspect gap. An empty pin list skips only the pin comparison (the
|
||||
//! manifest hash already binds content; pins arrive via `AUTHENTICODE_SHA256` in CI once
|
||||
//! the cert story settles — the field exists so Trusted Signing is a manifest edit).
|
||||
//! manifest hash already binds content).
|
||||
//!
|
||||
//! **Leaf pinning cannot be used with Azure Artifact Signing.** That service mints a fresh leaf
|
||||
//! per signing request, valid ~3 days, so an `AUTHENTICODE_SHA256` pin would go stale within days
|
||||
//! of publishing and reject every subsequent release. (An earlier note here assumed the opposite —
|
||||
//! that the pin field made Trusted Signing "a manifest edit". It does not.) If pinning is wanted
|
||||
//! against the Azure-signed artifacts, pin something stable instead: the issuing intermediate, or
|
||||
//! the certificate subject. Leave the list empty until then; the Ed25519-signed manifest hash is
|
||||
//! what actually binds the downloaded bytes.
|
||||
//!
|
||||
//! The spawn uses `CREATE_BREAKAWAY_FROM_JOB`: the service worker's job object is kill-on-close
|
||||
//! (a stopping service would otherwise take the installer down with it) and was created
|
||||
@@ -284,8 +292,10 @@ fn preflight_disk(at: &Path, needed: u64) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticode: valid embedded signature (untrusted root tolerated — self-signed `CN=unom`),
|
||||
/// signing-leaf SHA-256 ∈ `pins` when pins are present. The leaf comes out of the same
|
||||
/// Authenticode: valid embedded signature (untrusted root tolerated — canary/local builds are still
|
||||
/// self-signed), signing-leaf SHA-256 ∈ `pins` when pins are present — but see the module docs: a
|
||||
/// leaf pin is unusable against Azure-signed releases, whose leaf rotates every few days. The leaf
|
||||
/// comes out of the same
|
||||
/// `WinVerifyTrust` state via `WTHelperGetProvSignerFromChain`. (`pub(crate)`: the service
|
||||
/// supervisor's boot-loop rollback re-checks the cached previous installer with it.)
|
||||
pub(crate) fn verify_authenticode(path: &Path, pins: &[String]) -> Result<(), String> {
|
||||
|
||||
@@ -140,8 +140,61 @@ fresh install uses the generated random console password — read it from
|
||||
> punktfunk-planning: `windows-build-and-packaging.md` (internal planning repo) for the toolchain
|
||||
> + signing details.
|
||||
|
||||
## Installer signing (Azure Artifact Signing)
|
||||
|
||||
`setup.exe`, `punktfunk-host.exe`, `punktfunk-tray.exe` and the Vulkan HDR layer are signed with
|
||||
**Azure Artifact Signing** (formerly Trusted Signing): account `unomsigning`, certificate profile
|
||||
`unom-io`, endpoint `https://neu.codesigning.azure.net/`. It is a publicly trusted CA, so users get
|
||||
a named publisher in the UAC prompt and there is no `.cer` to import — `HOST_CER_PATH` is simply not
|
||||
emitted in this mode (every consumer already guards on `Test-Path`).
|
||||
|
||||
`pack-host-installer.ps1` resolves a backend in this order, first match wins:
|
||||
|
||||
| order | backend | selected by |
|
||||
| --- | --- | --- |
|
||||
| 1 | Azure Artifact Signing | `AZURE_CODESIGNING_ENDPOINT` + `_ACCOUNT` + `_PROFILE` all set |
|
||||
| 2 | stable self-signed `.pfx` | `MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` |
|
||||
| 3 | ephemeral self-signed | nothing set (canary / local only; a `v*` tag **fails closed**) |
|
||||
|
||||
Credentials for mode 1 come from the environment via `DefaultAzureCredential` — `AZURE_TENANT_ID`,
|
||||
`AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, the `punktfunk-ci-signing` service principal. It holds
|
||||
exactly one role, *Artifact Signing Certificate Profile Signer*, scoped to the `unom-io` profile: it
|
||||
can sign and can do nothing else with the subscription. The script hard-fails if the trio is missing
|
||||
rather than letting `DefaultAzureCredential` fall through to an interactive login that would hang a
|
||||
runner forever.
|
||||
|
||||
> **Timestamping is mandatory here, not best-effort.** Azure mints a leaf certificate per request,
|
||||
> valid for about three days. An untimestamped signature therefore goes untrusted within days of
|
||||
> release — it would verify fine on the runner and fail on users' machines that weekend. `Sign-File`
|
||||
> refuses to retry without a timestamp in Azure mode; modes 2 and 3 keep the old lenient retry, where
|
||||
> the cert outlives the release anyway.
|
||||
|
||||
### Runner setup
|
||||
|
||||
`signtool` reaches Azure through `Azure.CodeSigning.Dlib.dll`, which ships in the
|
||||
`Microsoft.Trusted.Signing.Client` NuGet package — no installer, no fixed path. On the Windows runner:
|
||||
|
||||
```powershell
|
||||
nuget install Microsoft.Trusted.Signing.Client -OutputDirectory $env:USERPROFILE\.nuget\packages
|
||||
```
|
||||
|
||||
`Find-AzureDlib` searches that path and `C:\trusted-signing\`, newest first, so a package update needs
|
||||
no script edit. Set `AZURE_CODESIGNING_DLIB` to override with an explicit path.
|
||||
|
||||
## Driver signing (`DRIVER_CERT_PFX_B64`)
|
||||
|
||||
> **The drivers are deliberately NOT on Azure.** Their catalogs keep the self-signed
|
||||
> `CN=punktfunk-driver` cert below, which the installer still plants in the machine `Root` store.
|
||||
> The two signatures are independent by design — Windows verifies the installer via SmartScreen/UAC
|
||||
> and driver catalogs via PnP, and never requires a common signer, which is why the installer could
|
||||
> move to a public CA without touching the driver track at all.
|
||||
>
|
||||
> Worth revisiting: these are **user-mode** (UMDF) drivers and we already clear `FORCE_INTEGRITY`, so
|
||||
> a catalog signed by the publicly trusted Azure cert would likely chain to a root every Windows box
|
||||
> already has — which would let us drop the `Root` plant entirely and keep only the `TrustedPublisher`
|
||||
> entry that suppresses the device-software prompt. That is a real reduction in what we ask of a
|
||||
> user's machine, but it is **unverified**: test it on the Windows box before believing it.
|
||||
|
||||
Our three UMDF drivers are signed with a **stable self-signed code-signing cert**, subject
|
||||
`CN=punktfunk-driver`, supplied to `build-pf-vdisplay.ps1` / `build-gamepad-drivers.ps1` as the
|
||||
`DRIVER_CERT_PFX_B64` + `DRIVER_CERT_PASSWORD` Actions secrets. On a `v*` tag build a missing cert
|
||||
@@ -241,7 +294,8 @@ the recovery. From a Linux box drive either over SSH, e.g.
|
||||
# statically links the vendored VPL dispatcher — needs cmake + a libclang, no FFmpeg)
|
||||
cargo build --release -p punktfunk-host --features nvenc,qsv
|
||||
|
||||
# 2. pack (self-signed unless MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD are set; -NoDriver to skip pf-vdisplay)
|
||||
# 2. pack (self-signed unless the AZURE_CODESIGNING_* trio or MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD
|
||||
# are set — see "Installer signing" above; -NoDriver to skip pf-vdisplay)
|
||||
pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetDir C:\t\release -OutDir C:\t\out
|
||||
```
|
||||
|
||||
|
||||
@@ -4,15 +4,24 @@
|
||||
|
||||
.DESCRIPTION
|
||||
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
|
||||
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,
|
||||
1. resolves a signing backend - Azure Artifact Signing (formerly Trusted Signing) when the
|
||||
AZURE_CODESIGNING_* trio is set, else a supplied stable .pfx from CI secrets, else an
|
||||
ephemeral self-signed CN=unom - same scheme as the client's pack-msix.ps1. The .pfx paths
|
||||
also export the public .cer; Azure does not (see below). 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,
|
||||
3. stages the pf-vdisplay virtual-display driver bundle (unless -NoDriver),
|
||||
4. runs ISCC to build punktfunk-host-setup-<ver>.exe,
|
||||
5. signs the setup.exe (timestamp best-effort),
|
||||
6. emits HOST_SETUP_PATH / HOST_CER_PATH to GITHUB_ENV for the publish step.
|
||||
5. signs the setup.exe (timestamped - MANDATORY under Azure signing, see Sign-File),
|
||||
6. emits HOST_SETUP_PATH / HOST_CER_PATH to GITHUB_ENV for the publish step. Azure signing
|
||||
emits no .cer: the chain is publicly trusted, so there is nothing for a user to import.
|
||||
Every consumer of HOST_CER_PATH already guards on Test-Path, so it is simply absent.
|
||||
|
||||
NOTE the drivers are signed separately, by build-pf-vdisplay.ps1 / build-gamepad-drivers.ps1 with
|
||||
the DRIVER_CERT_* secret, and are NOT re-signed here (that would invalidate their catalogs). The
|
||||
installer's signature and the driver catalogs' signatures are independent by design - Windows
|
||||
verifies the first via SmartScreen/UAC and the second via PnP, and never requires a common signer.
|
||||
|
||||
Idempotent; safe to re-run. Run on the Windows runner / dev box (MSVC + Windows SDK + Inno Setup).
|
||||
|
||||
@@ -24,9 +33,20 @@ param(
|
||||
[Parameter(Mandatory = $true)][string]$Version, # e.g. 0.2.137 or 1.4.0 (free-form)
|
||||
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release dir (has punktfunk-host.exe)
|
||||
[string]$OutDir = (Join-Path $TargetDir 'installer'),
|
||||
# Subject for the EPHEMERAL self-signed fallback only. Azure signing carries its own subject
|
||||
# (the profile's verified CN/O), and nothing downstream of setup.exe compares the two - unlike
|
||||
# the MSIX, whose manifest Identity/@Publisher must match byte-for-byte. See pack-msix.ps1.
|
||||
[string]$Publisher = 'CN=unom',
|
||||
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
|
||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||
# Azure Artifact Signing (formerly Trusted Signing). All three must be set to select it; it then
|
||||
# takes precedence over any .pfx. Credentials come from the environment via DefaultAzureCredential
|
||||
# (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET) - never passed as arguments, so they
|
||||
# cannot leak into a process listing or a transcript.
|
||||
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT, # e.g. https://neu.codesigning.azure.net/
|
||||
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT, # signing account name
|
||||
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE, # certificate profile name
|
||||
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB, # path to Azure.CodeSigning.Dlib.dll
|
||||
[string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build)
|
||||
[string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console
|
||||
[string]$ScriptingBundle = $env:SCRIPTING_BUNDLE, # built runner-cli.js -> bundle the plugin/script runner
|
||||
@@ -70,6 +90,29 @@ function Find-SdkTool([string]$name) {
|
||||
if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." }
|
||||
$hit.FullName
|
||||
}
|
||||
# Azure.CodeSigning.Dlib.dll ships in the Microsoft.Trusted.Signing.Client NuGet package, which has no
|
||||
# installer and no fixed location - hence an explicit override first, then the two paths the runner
|
||||
# setup uses (see packaging/windows/README.md). Newest version wins so a package update is picked up
|
||||
# without editing this script.
|
||||
function Find-AzureDlib([string]$Explicit) {
|
||||
if ($Explicit) {
|
||||
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
|
||||
return (Resolve-Path $Explicit).Path
|
||||
}
|
||||
$roots = @(
|
||||
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
|
||||
'C:\trusted-signing\microsoft.trusted.signing.client'
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
|
||||
Sort-Object LastWriteTime | Select-Object -Last 1
|
||||
if (-not $hit) {
|
||||
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
|
||||
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
|
||||
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
|
||||
}
|
||||
$hit.FullName
|
||||
}
|
||||
$iscc = Find-Iscc
|
||||
Write-Host "ISCC: $iscc"
|
||||
|
||||
@@ -87,20 +130,43 @@ if ($NoSign -and $requireCert) {
|
||||
}
|
||||
$pfxPath = Join-Path $OutDir 'signing.pfx'
|
||||
$cerPath = Join-Path $OutDir "punktfunk-host-windows_${Version}.cer"
|
||||
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
|
||||
$signMode = 'none'
|
||||
$signtool = $null
|
||||
if (-not $NoSign) {
|
||||
$signtool = Find-SdkTool 'signtool.exe'
|
||||
Write-Host "signtool: $signtool"
|
||||
if ($PfxBase64) {
|
||||
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
|
||||
$signMode = 'azure'
|
||||
$AzureDlib = Find-AzureDlib $AzureDlib
|
||||
# signtool reads the account/profile from this file (/dmdf) rather than the command line.
|
||||
@{
|
||||
Endpoint = $AzureEndpoint
|
||||
CodeSigningAccountName = $AzureAccount
|
||||
CertificateProfileName = $AzureProfile
|
||||
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
|
||||
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
|
||||
Write-Host " dlib: $AzureDlib"
|
||||
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
|
||||
if (-not [Environment]::GetEnvironmentVariable($v)) {
|
||||
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
|
||||
"DefaultAzureCredential; without the service-principal trio it falls through to " +
|
||||
"an interactive login that cannot complete on a runner and hangs the build.")
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($PfxBase64) {
|
||||
$signMode = 'pfx'
|
||||
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
|
||||
[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.")
|
||||
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
|
||||
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
|
||||
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
|
||||
}
|
||||
else {
|
||||
$signMode = 'selfsigned'
|
||||
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
|
||||
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
|
||||
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
|
||||
@@ -111,25 +177,44 @@ if (-not $NoSign) {
|
||||
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
|
||||
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
|
||||
}
|
||||
# Always export the public .cer. For a self-signed cert it's the file users import once
|
||||
# (LocalMachine\TrustedPublisher) so SmartScreen/UAC trusts the signed setup.exe; for a real CA
|
||||
# cert it's a harmless extra.
|
||||
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
|
||||
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
|
||||
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
|
||||
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
|
||||
# Export the public .cer for the .pfx-backed modes. For a self-signed cert it's the file users
|
||||
# import once (LocalMachine\TrustedPublisher) so SmartScreen/UAC trusts the signed setup.exe.
|
||||
# Azure signing has no .pfx to read and needs no import - the chain is publicly trusted - so it
|
||||
# deliberately produces no .cer and HOST_CER_PATH stays unset.
|
||||
if ($signMode -ne 'azure') {
|
||||
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
|
||||
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
|
||||
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
|
||||
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
|
||||
}
|
||||
}
|
||||
|
||||
# A timestamp is best-effort for a .pfx whose cert outlives the release, but MANDATORY under Azure
|
||||
# signing: those leaf certs are minted per request and expire in ~3 days, so an untimestamped
|
||||
# signature stops verifying within days of shipping. Retrying without one there would produce an
|
||||
# artifact that passes on the runner and fails on every user's machine that weekend - so the
|
||||
# fallback is gated on the mode rather than applied blindly.
|
||||
function Sign-File([string]$Path) {
|
||||
if ($NoSign) { return }
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $Path))
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($Path))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
|
||||
if ($signMode -eq 'azure') {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
|
||||
$ts = 'http://timestamp.acs.microsoft.com'
|
||||
}
|
||||
else {
|
||||
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
|
||||
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
|
||||
$ts = 'http://timestamp.digicert.com'
|
||||
}
|
||||
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
|
||||
if ($LASTEXITCODE -eq 0) { return }
|
||||
if ($signMode -eq 'azure') {
|
||||
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
|
||||
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
|
||||
"untrusted within days of release.")
|
||||
}
|
||||
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
|
||||
& $signtool ($signArgs + @($Path))
|
||||
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# --- sign the inner exes before they're packed -------------------------------------------------
|
||||
@@ -340,14 +425,18 @@ if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
|
||||
# --- sign the setup.exe + clean up ------------------------------------------------------------
|
||||
Sign-File $setup
|
||||
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==> installer: $setup"
|
||||
if (-not $NoSign) {
|
||||
if ($signMode -eq 'azure') {
|
||||
Write-Host "==> signed by a publicly trusted CA - nothing for users to import."
|
||||
}
|
||||
elseif (-not $NoSign) {
|
||||
Write-Host "==> trust the cert once per machine (self-signed builds), then the signed setup.exe is trusted:"
|
||||
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPublisher"
|
||||
}
|
||||
if ($env:GITHUB_ENV) {
|
||||
"HOST_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
if (-not $NoSign) { "HOST_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
|
||||
if (-not $NoSign -and $signMode -ne 'azure') { "HOST_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
|
||||
}
|
||||
|
||||
@@ -107,10 +107,10 @@ These manifests stay in winget-pkgs' own format rather than a bespoke one, so su
|
||||
later is a copy, not a rewrite. Two things would need attention on that path: the signing note
|
||||
below, and `Agreements` being verified-developers-only in the community repo.
|
||||
|
||||
> **Signing.** The installer is currently signed with a self-signed cert (`CN=unom`, subject ==
|
||||
> issuer) and ships a `.cer` users import manually. winget does not sign anything; it downloads and
|
||||
> runs the same binary, so SmartScreen behaves exactly as it does for a browser download. That is a
|
||||
> pre-existing condition rather than something winget introduces — but the community repo
|
||||
> (`microsoft/winget-pkgs`) gates on it via its `Binary-Validation-Error` /
|
||||
> `Validation-Defender-Error` checks, so a submission there needs a publicly-trusted cert (Azure
|
||||
> Trusted Signing is the cheap path). A self-hosted source has no such gate.
|
||||
> **Signing.** The installer is signed with **Azure Artifact Signing** (account `unomsigning`,
|
||||
> profile `unom-io`) — a publicly trusted CA, so there is no `.cer` for users to import. This
|
||||
> removed the blocker on submitting to the community repo (`microsoft/winget-pkgs`), whose
|
||||
> `Binary-Validation-Error` / `Validation-Defender-Error` checks require a publicly trusted cert;
|
||||
> the remaining upstream obstacle is `Agreements` being verified-developers-only. Note that a
|
||||
> trusted cert is not an instant SmartScreen bypass: reputation still accrues per publisher over
|
||||
> downloads, it just now accrues to a named identity instead of being permanently unknown.
|
||||
|
||||
@@ -16,12 +16,10 @@ winget upgrade unom.PunktfunkHost
|
||||
## Why self-hosted rather than the community repo
|
||||
|
||||
`microsoft/winget-pkgs` gates submissions on its `Binary-Validation-Error` /
|
||||
`Validation-Defender-Error` checks, and the host installer is currently signed with a self-signed
|
||||
cert (`CN=unom`). That is a pre-existing condition — winget does not sign anything, so SmartScreen
|
||||
behaves identically whether the installer arrives by browser or by `winget` — but it does block that
|
||||
route until a publicly trusted cert is in place. A self-hosted source has no such gate, can carry
|
||||
`Agreements` (verified-developers-only upstream), and can serve channels the community repo would
|
||||
never accept.
|
||||
`Validation-Defender-Error` checks, which need a publicly trusted signing cert. That blocker is
|
||||
gone — the host installer is now signed with Azure Artifact Signing (see `packaging/windows/README.md`)
|
||||
— but a self-hosted source is still the right call: it has no such gate, can carry `Agreements`
|
||||
(verified-developers-only upstream), and can serve channels the community repo would never accept.
|
||||
|
||||
## What it implements
|
||||
|
||||
|
||||
@@ -113,4 +113,60 @@ $env:PATH = "C:\Users\Public\ffmpeg\bin;" + $env:PATH
|
||||
'@ | Set-Content -Encoding UTF8 $projectEnv
|
||||
info "wrote $projectEnv (FFMPEG_DIR) - restart the gitea-act-runner scheduled task to pick it up"
|
||||
|
||||
# --- Azure Artifact Signing (formerly Trusted Signing) toolchain, for the signing step in
|
||||
# windows-host.yml + windows-client.yml. Two pieces, neither of which the generic unom/infra image
|
||||
# carries, and both of which fail in ways that do not name themselves:
|
||||
#
|
||||
# 1. The .NET 8 runtime. Azure.CodeSigning.Dlib.dll is a mixed-mode (C++/CLI) assembly - it ships
|
||||
# Ijwhost.dll and a runtimeconfig.json pinning Microsoft.NETCore.App 8.0.0 - so on a box with
|
||||
# no .NET runtime, signtool exits 3 having printed NOTHING AT ALL. Verified on .133 2026-08-14:
|
||||
# the box had pwsh 7 (self-contained, brings no shared runtime) and no dotnet whatsoever.
|
||||
# 2. The signing client, installed MACHINE-WIDE under C:\trusted-signing rather than into a user's
|
||||
# .nuget. The act_runner daemon runs as SYSTEM, whose USERPROFILE is
|
||||
# C:\Windows\System32\config\systemprofile - so a per-user install under Administrator is
|
||||
# invisible to every job that actually builds. Find-AzureDlib in both pack scripts searches
|
||||
# this exact path for that reason; verified by resolving it from a SYSTEM scheduled task.
|
||||
#
|
||||
# Both are SHA-256 pinned against version-immutable URLs (a nuget.org flat-container package and the
|
||||
# dotnet builds CDN are both immutable per version), so these fail closed on tampering rather than
|
||||
# every time Microsoft ships a patch release. Bump version + hash together to move either. ---
|
||||
$dotnetVer = '8.0.30'
|
||||
$dotnetSha = 'E40F199C6D5584AFF0554C01163C3C8D9CCF6BEC3A577E4D967E41070772A1C1'
|
||||
$tscVer = '1.0.95'
|
||||
$tscSha = '3BFCF1E0A3CB42AF1692F0A8ED45C15DE070C2DE86F28A59B2795D904D8A920F'
|
||||
|
||||
if (Test-Path 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App') {
|
||||
info "shared .NET runtime already present ($((Get-ChildItem 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App' | ForEach-Object Name) -join ', '))"
|
||||
} else {
|
||||
info "installing .NET $dotnetVer runtime (required by Azure.CodeSigning.Dlib.dll)"
|
||||
$dn = "$env:TEMP\dotnet-runtime-$dotnetVer-win-x64.exe"
|
||||
Invoke-WebRequest -Uri "https://builds.dotnet.microsoft.com/dotnet/Runtime/$dotnetVer/dotnet-runtime-$dotnetVer-win-x64.exe" -OutFile $dn -UseBasicParsing
|
||||
$got = (Get-FileHash $dn -Algorithm SHA256).Hash
|
||||
if ($got -ne $dotnetSha) { Remove-Item $dn -Force; throw ".NET runtime download hash mismatch (got $got, pinned $dotnetSha)." }
|
||||
# -Wait is load-bearing: the bundle is a GUI PE that returns immediately when invoked with &,
|
||||
# leaving $LASTEXITCODE unset and racing any completion check against the install.
|
||||
$p = Start-Process -FilePath $dn -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru
|
||||
Remove-Item $dn -Force -ErrorAction SilentlyContinue
|
||||
if ($p.ExitCode -ne 0) { throw ".NET runtime installer exited $($p.ExitCode)." }
|
||||
if (-not (Test-Path 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App')) { throw ".NET runtime installer reported success but installed no shared runtime." }
|
||||
}
|
||||
|
||||
$tscDir = "C:\trusted-signing\microsoft.trusted.signing.client\$tscVer"
|
||||
if (Test-Path (Join-Path $tscDir 'bin\x64\Azure.CodeSigning.Dlib.dll')) {
|
||||
info "Trusted Signing client $tscVer already present at $tscDir"
|
||||
} else {
|
||||
info "installing Microsoft.Trusted.Signing.Client $tscVer (machine-wide, for SYSTEM)"
|
||||
$nupkg = "$env:TEMP\microsoft.trusted.signing.client.$tscVer.nupkg"
|
||||
Invoke-WebRequest -Uri "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$tscVer/microsoft.trusted.signing.client.$tscVer.nupkg" -OutFile $nupkg -UseBasicParsing
|
||||
$got = (Get-FileHash $nupkg -Algorithm SHA256).Hash
|
||||
if ($got -ne $tscSha) { Remove-Item $nupkg -Force; throw "Trusted Signing client download hash mismatch (got $got, pinned $tscSha)." }
|
||||
if (Test-Path $tscDir) { Remove-Item -Recurse -Force $tscDir }
|
||||
New-Item -ItemType Directory -Force -Path $tscDir | Out-Null
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($nupkg, $tscDir)
|
||||
Remove-Item $nupkg -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $tscDir -Recurse -File | Unblock-File -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path (Join-Path $tscDir 'bin\x64\Azure.CodeSigning.Dlib.dll'))) { throw "extracted $tscVer but bin\x64\Azure.CodeSigning.Dlib.dll is absent." }
|
||||
}
|
||||
|
||||
info "punktfunk extras provisioned OK."
|
||||
|
||||
Reference in New Issue
Block a user