ci: Authenticode-sign the agent binaries before the release hashes are computed (D-223)

Every assembly we author -- AutopilotMonitor.Agent.exe, AutopilotMonitor.SummaryDialog.exe
and the three AutopilotMonitor.*.dll beside them -- is signed with AzureSignTool (pinned
7.0.1) against a certificate in the corporate code-signing Key Vault, SHA-256 file digest
plus an RFC-3161 timestamp. Third-party assemblies keep their own publisher's signature.

The position of the step is load-bearing: signing rewrites the PE file, so it has to run
before the staging copy, the ZIP and every SHA-256 in "Create release artifact". Those
hashes are the bootstrap's integrity oracle (version.json, AdminConfiguration) -- signing
after hashing would publish hashes that no downloaded file can ever match.

The signing identity is a SECOND federated principal in the same tenant and logs in FIRST,
only to fetch a Key Vault token; the storage login of D-222 follows and owns the az context
from there, because azure/login sets the default account and the last login wins. Both
federated credentials carry the subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main,
so this job must never be given a GitHub 'environment:' -- that rewrites the OIDC subject
claim and would break BOTH logins at once.

No unsigned fallback and no dispatch input to skip signing: a missing repository variable,
a Key Vault failure or a non-zero azuresigntool exit code fails the run before anything is
published. azuresigntool is a native tool, so $LASTEXITCODE is checked explicitly --
PowerShell does not throw on a non-zero exit, and a silently unsigned release is exactly
the outcome signing exists to prevent.

Verification runs against the STAGED bytes, not the build output: every AutopilotMonitor.*
PE in the ZIP must be Valid, must carry a timestamp (without one the signature dies with
the certificate) and all of them must share one signing certificate. Setting the optional
repository variable CODE_SIGN_EXPECTED_SUBJECT additionally pins the publisher, so the gate
proves "signed by us" instead of "signed by someone trusted".

The build provenance attestation now covers the ZIP and both EXEs, so an extracted binary
can be verified without the ZIP. Authenticode and the attestation answer different
questions and both stay: who published this binary (offline, what Windows, SmartScreen and
WDAC evaluate) versus which build produced these exact bytes (gh attestation verify).

Not covered here: the customer bootstrap script (its signature block changes the published
bytes and must follow the dev render; see B-57c, the two-stage loader), the bootstrap MSI,
and the local build.ps1 path, which from now on produces unsigned bits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-08 18:20:16 +02:00
parent e4a5a6654d
commit 21ec5e4773
+164 -6
View File
@@ -57,6 +57,16 @@ env:
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
STABLE_ZIP: AutopilotMonitor-Agent.zip
STABLE_MANIFEST: version.json
# Authenticode via Azure Key Vault. Vault URL and client id stay in repository variables:
# this repo is public and infrastructure names do not belong in it. The signing identity
# lives in the same tenant as the deploy identity, so AZURE_TENANT_ID covers both.
# CODE_SIGN_EXPECTED_SUBJECT is optional -- when set, the verify step pins the publisher
# instead of accepting any trusted signature.
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
jobs:
build:
@@ -67,6 +77,45 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
# Fail before a build number is burnt if the signing configuration is incomplete.
# Every released build is signed -- there is no unsigned fallback, so a missing
# variable is a stop, not a warning.
- name: Check code-signing configuration
shell: pwsh
run: |
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Repository variable $name is not set. Every released build must be Authenticode-signed."
}
}
Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')"
# The signing identity is a SECOND federated principal in the same tenant, and it logs in
# FIRST on purpose: azure/login sets the default az account, so the storage login below
# would otherwise be overwritten. Only the Key Vault token is taken from this context --
# a plain string that survives the switch -- and everything after this step runs as the
# deploy principal again.
# Both federated credentials are bound to the same subject
# (repo:okieselbach/AutopilotMonitor:ref:refs/heads/main). Never add an `environment:` to
# this job: that rewrites the OIDC subject claim and breaks BOTH logins at once.
- name: Login to Azure (code-signing identity)
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
with:
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Acquire Key Vault token for signing
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
throw "Could not acquire a Key Vault token for the code-signing identity."
}
Write-Host "::add-mask::$token"
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# Every storage write below (counter blob, agent ZIP, manifests, AdminConfiguration)
# is authorized by this federated identity -- no SAS secret. One login covers the whole
# job: the federated credential is bound to the ref, and a release run takes ~2 minutes,
@@ -85,6 +134,13 @@ jobs:
with:
dotnet-version: '10.0.x'
# Version pinned: a new or compromised NuGet release must not flow into signing unreviewed.
# LatestMajor roll-forward because the tool targets an older runtime than the SDK above.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
# Build numbers come from the shared counter blob -- one counter and one
# implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1).
# Reserve-before-build with ETag-CAS: concurrent builds can never mint the same
@@ -140,6 +196,52 @@ jobs:
- name: Run Agent unit tests
run: dotnet test ${{ env.TEST_PROJECT }} --configuration Release --no-restore --verbosity quiet
# Authenticode over everything we author: the two EXEs that run on a device and our own
# DLLs beside them. Third-party assemblies keep their publisher's signature -- re-signing
# would replace it.
#
# THE POSITION IS LOAD-BEARING: signing rewrites the PE file. It must happen before the
# staging copy, the ZIP and every SHA-256 in the next step, because those hashes are the
# bootstrap's integrity oracle (version.json / AdminConfiguration). Signing afterwards
# would publish hashes that no downloaded file can ever match.
#
# azuresigntool is a native tool, so $LASTEXITCODE must be checked explicitly -- PowerShell
# does not throw on a non-zero exit, and a silently unsigned release is exactly the
# outcome signing exists to prevent.
- name: Sign agent binaries (Authenticode)
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
$targets = @()
$targets += Get-ChildItem -Path "$env:AGENT_OUTPUT/*" -Include 'AutopilotMonitor.*.exe', 'AutopilotMonitor.*.dll' -File
$targets += Get-ChildItem -Path "$env:DIALOG_OUTPUT/AutopilotMonitor.SummaryDialog.exe" -File
if (-not ($targets | Where-Object Name -eq 'AutopilotMonitor.Agent.exe')) {
throw "AutopilotMonitor.Agent.exe not found in $env:AGENT_OUTPUT -- nothing to sign."
}
if (-not ($targets | Where-Object Name -eq 'AutopilotMonitor.SummaryDialog.exe')) {
throw "AutopilotMonitor.SummaryDialog.exe not found in $env:DIALOG_OUTPUT -- nothing to sign."
}
$targets | ForEach-Object { Write-Host "Signing $($_.Name)" }
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description "Autopilot Monitor Agent" `
--description-url "https://www.autopilotmonitor.com" `
($targets | ForEach-Object { $_.FullName })
if ($LASTEXITCODE -ne 0) {
throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published."
}
- name: Create release artifact
id: artifact
shell: pwsh
@@ -230,6 +332,54 @@ jobs:
"sha256=$sha256Hash" >> $env:GITHUB_OUTPUT
"exe_sha256=$exeSha256Hash" >> $env:GITHUB_OUTPUT
# Verified against the STAGING copy -- the exact bytes that went into the ZIP and into
# both hashes, not the build output. The invariant is "every AutopilotMonitor PE in the
# shipped ZIP is signed", so a project added later cannot ride along unsigned.
# A missing timestamp is a failure, not a detail: without it the signature dies with the
# certificate. `Valid` alone only proves *a* trusted signature -- set the repository
# variable CODE_SIGN_EXPECTED_SUBJECT to pin the publisher as well.
- name: Verify Authenticode signatures
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$files = Get-ChildItem -Path "staging/agent/*" -Include 'AutopilotMonitor.*.exe', 'AutopilotMonitor.*.dll' -File
if ($files.Count -lt 2) {
throw "Expected signed binaries in staging/agent, found $($files.Count)."
}
$failed = $false
$thumbprints = @{}
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature -FilePath $file.FullName
if ($sig.Status -ne 'Valid') {
Write-Host "::error file=$($file.Name)::Signature status is '$($sig.Status)' -- expected 'Valid'."
$failed = $true
continue
}
if (-not $sig.TimeStamperCertificate) {
Write-Host "::error file=$($file.Name)::Signature carries no RFC-3161 timestamp -- it would expire with the certificate."
$failed = $true
continue
}
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and
$sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
Write-Host "::error file=$($file.Name)::Signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
$failed = $true
continue
}
$thumbprints[$sig.SignerCertificate.Thumbprint] = $true
Write-Host "$($file.Name): Valid, timestamped $($sig.TimeStamperCertificate.Subject.Split(',')[0]), signer $($sig.SignerCertificate.Subject)"
}
if ($thumbprints.Count -gt 1) {
Write-Host "::error::Staged binaries carry $($thumbprints.Count) different signing certificates -- expected exactly one."
$failed = $true
}
if ($failed) {
throw "Signature verification failed. Nothing was published."
}
Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')"
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
shell: pwsh
@@ -312,14 +462,22 @@ jobs:
Write-Host "AdminConfiguration updated: V2 version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256"
}
# Sigstore keyless provenance: binds the ZIP digest to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log. The digest covers the identical
# Sigstore keyless provenance: binds the artifact digests to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log. The digests cover the identical
# bytes uploaded to blob (versioned AND stable), so one attestation covers all copies.
# Verify: gh attestation verify <zip> --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (ZIP)
# Complementary to Authenticode, not a substitute: the signature says who published the
# binary and is what Windows, SmartScreen and WDAC evaluate; the attestation says which
# build produced these exact bytes and is checked with `gh attestation verify`. The two
# EXEs are subjects of their own so an extracted binary can be verified without the ZIP.
# Runs after signing -- the digests must be those of the signed files.
# Verify: gh attestation verify <file> --repo okieselbach/AutopilotMonitor
- name: Attest build provenance
uses: actions/attest-build-provenance@v4
with:
subject-path: ${{ steps.artifact.outputs.versioned_zip_release }}
subject-path: |
${{ steps.artifact.outputs.versioned_zip_release }}
staging/agent/AutopilotMonitor.Agent.exe
staging/agent/AutopilotMonitor.SummaryDialog.exe
- name: Upload build artifact
uses: actions/upload-artifact@v7
@@ -361,7 +519,7 @@ jobs:
- **Published as stable:** ${{ inputs.publish_as_stable }}
### Provenance
This ZIP carries a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
```
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
```