From b271a142e4874a9a3fff85bacaefdee9506f7258 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 10 Oct 2026 13:53:58 -0500 Subject: [PATCH 1/2] feat(release): Authenticode-sign releases with Azure Artifact Signing over OIDC Turns on the shared release workflow's `sign` input: every published exe/dll is signed with Azure Artifact Signing (SHA-256, RFC 3161 timestamp) before packing, and vpk signs Setup.exe and the update stub with the same identity. The identity is an Entra application with a federated credential bound to this repository's `release` environment and the Certificate Profile Signer role on the profile only; it is configuration (release-environment variables), not a secret. A verify-signatures job downloads the published Setup.exe, portable and CLI zips and runs signtool verify /pa /all plus Get-AuthenticodeSignature on every gclo executable, checking status, timestamp and the validated publisher name, so an unsigned release fails the run. Set-ReleaseSigning.ps1 records the idempotent Azure configuration; RELEASING.md, SECURITY.md, README, ADR 0008 and the threat model describe the result and local signing. Closes #57 (standards exception EX-0002 closes for gclo after the first signed release verifies). Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Maes --- .github/scripts/Set-ReleaseSigning.ps1 | 153 ++++++++++++++++++++++++ .github/workflows/release.yml | 72 ++++++++++- README.md | 8 +- SECURITY.md | 12 +- docs/RELEASING.md | 65 +++++++++- docs/adr/0008-azure-artifact-signing.md | 60 ++++++++++ docs/security/threat-model.md | 1 + 7 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/Set-ReleaseSigning.ps1 create mode 100644 docs/adr/0008-azure-artifact-signing.md diff --git a/.github/scripts/Set-ReleaseSigning.ps1 b/.github/scripts/Set-ReleaseSigning.ps1 new file mode 100644 index 0000000..eb797d1 --- /dev/null +++ b/.github/scripts/Set-ReleaseSigning.ps1 @@ -0,0 +1,153 @@ +# Copyright (c) 2026 James Maes (KofTwentyTwo) +# SPDX-License-Identifier: MIT + +<# +.SYNOPSIS + Configures the Azure side of gclo's release signing (ADR 0008) and prints the + GitHub `release`-environment variables the workflow needs. + +.DESCRIPTION + Idempotent. Run interactively by the owner after `Connect-AzAccount` into the + KofTwentyTwo tenant (Az.Accounts, Az.Resources and Az.CodeSigning modules). + It never creates a secret, key or certificate and never touches the signing + account, the publisher validation or the certificate profile themselves: + + 1. Reads the Artifact Signing account and its certificate profile and fails if + either is missing or the profile is not Active. + 2. Ensures an Entra application + service principal for this repository. + 3. Ensures a federated credential on it that trusts GitHub's OIDC issuer for + subject `repo::environment:` only. + 4. Ensures the service principal holds "Artifact Signing Certificate Profile + Signer" scoped to the certificate profile, and nothing broader. + 5. Prints the variable values to set with `gh variable set --env release`. + +.PARAMETER Repository + The GitHub repository the credential is bound to (owner/name). + +.PARAMETER Environment + The GitHub environment whose jobs may sign (its deployment rule limits it to + v* tags). + +.PARAMETER ResourceGroup + Resource group that holds the signing account. + +.PARAMETER AccountName + The Artifact Signing account name. + +.PARAMETER ProfileName + The certificate profile name on that account. + +.PARAMETER ApplicationName + Display name of the Entra application that represents this repository's + release job. + +.EXAMPLE + Connect-AzAccount -Tenant koftwentytwooutlook.onmicrosoft.com + ./.github/scripts/Set-ReleaseSigning.ps1 +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string] $Repository = 'KofTwentyTwo/gclo', + [string] $Environment = 'release', + [string] $ResourceGroup = 'rg-code-signing', + [string] $AccountName = 'kof22signing', + [string] $ProfileName = 'releases', + [string] $ApplicationName = 'gclo-release-signing' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$signerRole = 'Artifact Signing Certificate Profile Signer' +$issuer = 'https://token.actions.githubusercontent.com' +$subject = "repo:$Repository`:environment:$Environment" + +$context = Get-AzContext +if($null -eq $context) +{ + throw 'Not signed in. Run Connect-AzAccount into the KofTwentyTwo tenant first.' +} +Write-Output "Tenant $($context.Tenant.Id), subscription '$($context.Subscription.Name)' ($($context.Subscription.Id))" + +# 1. The signing account and profile must already exist; this script never creates them. +# Az.CodeSigning has no account/profile cmdlets, so this reads the ARM resources. +$apiVersion = '2024-09-30-preview' +$accountPath = "/subscriptions/$($context.Subscription.Id)/resourceGroups/$ResourceGroup/providers/Microsoft.CodeSigning/codeSigningAccounts/$AccountName" +$accountResponse = Invoke-AzRestMethod -Method GET -Path "$accountPath`?api-version=$apiVersion" +if($accountResponse.StatusCode -ne 200) +{ + throw "Signing account '$AccountName' not found in '$ResourceGroup' (HTTP $($accountResponse.StatusCode)): $($accountResponse.Content)" +} +$account = $accountResponse.Content | ConvertFrom-Json +$certProfileResponse = Invoke-AzRestMethod -Method GET -Path "$accountPath/certificateProfiles/$ProfileName`?api-version=$apiVersion" +if($certProfileResponse.StatusCode -ne 200) +{ + throw "Certificate profile '$ProfileName' not found on '$AccountName' (HTTP $($certProfileResponse.StatusCode)): $($certProfileResponse.Content)" +} +$certProfile = $certProfileResponse.Content | ConvertFrom-Json +if($certProfile.properties.status -ne 'Active') +{ + throw "Certificate profile '$ProfileName' is '$($certProfile.properties.status)', not Active." +} +$endpoint = $account.properties.accountUri +$subjectName = $certProfile.properties.commonName +Write-Output "Signing account '$AccountName' in $($account.location): endpoint $endpoint." +Write-Output "Profile '$ProfileName' is Active (type $($certProfile.properties.profileType)); certificate subject CN='$subjectName'." + +# 2. One application per repository, so the trust can be revoked per repository. +$app = Get-AzADApplication -DisplayName $ApplicationName | Select-Object -First 1 +if($null -eq $app) +{ + if($PSCmdlet.ShouldProcess($ApplicationName, 'Create Entra application')) + { + $app = New-AzADApplication -DisplayName $ApplicationName -SignInAudience AzureADMyOrg + } +} +$sp = Get-AzADServicePrincipal -ApplicationId $app.AppId +if($null -eq $sp) +{ + if($PSCmdlet.ShouldProcess($ApplicationName, 'Create service principal')) + { + $sp = New-AzADServicePrincipal -ApplicationId $app.AppId + } +} +Write-Output "Application '$ApplicationName': client id $($app.AppId), service principal $($sp.Id)." + +# 3. Federated credential: GitHub OIDC, this repository's release environment only. +$credentialName = "github-$($Repository.Replace('/', '-'))-$Environment" +$existing = Get-AzADAppFederatedCredential -ApplicationObjectId $app.Id | Where-Object { $_.Subject -eq $subject -and $_.Issuer -eq $issuer } +if($null -eq $existing) +{ + if($PSCmdlet.ShouldProcess($credentialName, "Create federated credential for $subject")) + { + New-AzADAppFederatedCredential -ApplicationObjectId $app.Id -Name $credentialName -Issuer $issuer -Subject $subject -Audience 'api://AzureADTokenExchange' | Out-Null + } +} +Write-Output "Federated credential trusts $issuer for subject '$subject'." + +# 4. Signer role on the certificate profile only. Owner/Contributor grant no signing right. +$profileScope = $certProfile.Id +$assignment = Get-AzRoleAssignment -ObjectId $sp.Id -Scope $profileScope -RoleDefinitionName $signerRole -ErrorAction SilentlyContinue +if($null -eq $assignment) +{ + if($PSCmdlet.ShouldProcess($profileScope, "Assign '$signerRole'")) + { + New-AzRoleAssignment -ObjectId $sp.Id -Scope $profileScope -RoleDefinitionName $signerRole | Out-Null + } +} +$broader = Get-AzRoleAssignment -ObjectId $sp.Id | Where-Object { $_.Scope -ne $profileScope } +if($broader) +{ + Write-Warning "The service principal also holds: $(($broader | ForEach-Object { "$($_.RoleDefinitionName) @ $($_.Scope)" }) -join '; '). Remove anything it does not need." +} +Write-Output "'$signerRole' is assigned at $profileScope." + +# 5. The values the workflow reads (none of them is a secret). +Write-Output '' +Write-Output "gh variable set AZURE_TENANT_ID --env $Environment --repo $Repository --body '$($context.Tenant.Id)'" +Write-Output "gh variable set AZURE_CLIENT_ID --env $Environment --repo $Repository --body '$($app.AppId)'" +Write-Output "gh variable set AZURE_SUBSCRIPTION_ID --env $Environment --repo $Repository --body '$($context.Subscription.Id)'" +Write-Output "gh variable set TRUSTED_SIGNING_ENDPOINT --env $Environment --repo $Repository --body '$endpoint'" +Write-Output "gh variable set TRUSTED_SIGNING_ACCOUNT --env $Environment --repo $Repository --body '$AccountName'" +Write-Output "gh variable set TRUSTED_SIGNING_PROFILE --env $Environment --repo $Repository --body '$ProfileName'" +Write-Output "gh variable set TRUSTED_SIGNING_SUBJECT --env $Environment --repo $Repository --body '$subjectName'" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa68994..c189266 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -164,7 +164,77 @@ jobs: gclo.Engine.Tests gclo.Cli.Tests cli-project: gclo.Cli/gclo.Cli.csproj - sign: false # standards exception EX-0002 (#57): true once the Azure Trusted Signing identity is validated + # Authenticode (Azure Artifact Signing, formerly Trusted Signing) over OIDC: the + # shared workflow signs every published exe/dll and vpk signs Setup.exe and the + # update stub with the same identity. The identity lives in the `release` + # environment's variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, + # AZURE_SUBSCRIPTION_ID, TRUSTED_SIGNING_ENDPOINT, TRUSTED_SIGNING_ACCOUNT, + # TRUSTED_SIGNING_PROFILE, docs/RELEASING.md) and is granted only to this + # repository's v* tags through a federated credential (#57, closes EX-0002). + sign: true + + # The shipped artifacts carry valid Authenticode signatures: every exe inside the + # installer, the portable zip and the CLI zip, and Setup.exe itself, verify with + # `signtool verify /pa /all` and show the publisher the certificate profile was + # validated for. Runs on what was actually published, so a signing step that + # silently produced unsigned files fails the run (and the release is then + # handled per "A broken release" in docs/RELEASING.md), not a later user. + verify-signatures: + name: Verify signatures on the published assets + needs: release + runs-on: windows-latest + timeout-minutes: 15 + permissions: + contents: read + env: + TAG: ${{ github.ref_name }} + K22_VERSION: ${{ needs.release.outputs.version }} + K22_CHANNEL: ${{ needs.release.outputs.prerelease == 'true' && 'dev' || 'stable' }} + EXPECTED_SUBJECT: ${{ vars.TRUSTED_SIGNING_SUBJECT }} + steps: + - name: Download the published assets + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + New-Item -ItemType Directory -Force assets | Out-Null + gh release download $env:TAG --dir assets --pattern "gclo-$env:K22_CHANNEL-Setup.exe" --pattern "gclo-$env:K22_CHANNEL-Portable.zip" --pattern "gclo-cli-win-x64-$env:K22_VERSION.zip" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Expand-Archive assets/gclo-$env:K22_CHANNEL-Portable.zip -DestinationPath assets/portable + Expand-Archive assets/gclo-cli-win-x64-$env:K22_VERSION.zip -DestinationPath assets/cli + + - name: signtool verify /pa /all on every shipped executable + shell: pwsh + run: | + if ([string]::IsNullOrEmpty($env:EXPECTED_SUBJECT)) + { + Write-Output '::error::TRUSTED_SIGNING_SUBJECT is not set in the release environment (docs/RELEASING.md).' + exit 1 + } + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe | + Where-Object { $_.FullName -match '\\x64\\' } | Sort-Object FullName -Descending | Select-Object -First 1 + $files = @(Get-Item assets/gclo-$env:K22_CHANNEL-Setup.exe) + @(Get-ChildItem assets/portable, assets/cli -Recurse -Include *.exe, *.dll | + Where-Object { $_.Name -like 'gclo*' -or $_.Name -in 'Update.exe' }) + $failed = 0 + foreach ($f in $files) + { + & $signtool.FullName verify /pa /all /v $f.FullName | Out-Null + $sig = Get-AuthenticodeSignature -LiteralPath $f.FullName + $subject = $sig.SignerCertificate.Subject + $timestamped = $null -ne $sig.TimeStamperCertificate + if ($LASTEXITCODE -ne 0 -or $sig.Status -ne 'Valid' -or -not $timestamped -or $subject -notlike "*$env:EXPECTED_SUBJECT*") + { + Write-Output "::error file=$($f.Name)::signature status '$($sig.Status)', timestamped=$timestamped, subject '$subject' (expected '*$env:EXPECTED_SUBJECT*', signtool exit $LASTEXITCODE)" + $failed++ + } + else + { + Write-Output "ok $($f.Name) $subject (timestamped)" + } + } + if ($failed -gt 0) { exit 1 } + Write-Output "All $($files.Count) shipped executables carry a valid, timestamped signature by '$env:EXPECTED_SUBJECT'." # Scoop and Chocolatey (#9), stable releases only. The repository is its own # Scoop bucket (bucket/gclo.json) and ships the Chocolatey package source diff --git a/README.md b/README.md index 17ccefb..a280bcb 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,15 @@ gh attestation verify .\gclo-stable-Setup.exe --repo KofTwentyTwo/gclo --signer- # 3. Dependencies: the SBOM is attested too gh attestation verify .\gclo-cli-win-x64-.zip --repo KofTwentyTwo/gclo --signer-repo KofTwentyTwo/standards --predicate-type https://cyclonedx.org/bom + +# 4. Publisher: Setup.exe and every gclo executable are Authenticode-signed (Azure +# Artifact Signing, SHA-256, RFC 3161 timestamp) by KofTwentyTwo +signtool verify /pa /all /v .\gclo-stable-Setup.exe +Get-AuthenticodeSignature .\gclo-stable-Setup.exe | Format-List Status, SignerCertificate, TimeStamperCertificate ``` -The binaries are not yet Authenticode-signed (standards exception [EX-0002](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0002), [#57](https://github.com/KofTwentyTwo/gclo/issues/57)), so step 2 is the origin check until `Get-AuthenticodeSignature` can be. +Windows checks step 4 for you: a signed installer carries the publisher name in the +SmartScreen and UAC prompts instead of "Unknown publisher". ## Getting a token diff --git a/SECURITY.md b/SECURITY.md index 14bfe7e..c2e7137 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -110,8 +110,10 @@ is a vulnerability: please report it through the channel above. [README](README.md#verifying-a-release). - Self-update downloads only from this repository's GitHub Releases and verifies each package against the release's `releases..json` feed. -- The binaries are **not yet Authenticode-signed**: KofTwentyTwo standards exception - [EX-0002](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0002) - (tracked here as [#57](https://github.com/KofTwentyTwo/gclo/issues/57)). Until they - are, expect a SmartScreen prompt on first install, and prefer the checksum and - attestation checks over trusting a download by its name. +- Setup.exe, the update stub and every gclo executable and library are + **Authenticode-signed** with Azure Artifact Signing (SHA-256, RFC 3161 timestamp) in + the release workflow, over OIDC with an identity that only this repository's release + tags can use; no signing key or secret exists outside Azure. The workflow verifies + the published assets with `signtool verify /pa /all` and fails if any shipped + executable is unsigned or signed by another publisher. Check the publisher yourself + with `Get-AuthenticodeSignature` (README, "Verifying a release"). diff --git a/docs/RELEASING.md b/docs/RELEASING.md index a7cfe3a..aa730c8 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -64,7 +64,8 @@ its first submission cannot be automated; see below. | Job | Runs for | Needs | Publishes | | --- | -------- | ----- | --------- | -| `release` (shared workflow) | every tag | nothing beyond `GITHUB_TOKEN` | the GitHub Release: Setup, portable, full/delta packages, feed, CLI zip, SBOMs, `SHA256SUMS`, provenance and SBOM attestations | +| `release` (shared workflow) | every tag | the signing identity, as `release`-environment **variables** (no secret exists): `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_SUBSCRIPTION_ID`, `TRUSTED_SIGNING_ENDPOINT`, `TRUSTED_SIGNING_ACCOUNT`, `TRUSTED_SIGNING_PROFILE` (see "Code signing") | the GitHub Release: Setup, portable, full/delta packages, feed, CLI zip, SBOMs, `SHA256SUMS`, provenance and SBOM attestations; every exe/dll and Setup.exe Authenticode-signed | +| `verify-signatures` | every tag | `TRUSTED_SIGNING_SUBJECT` (`release`-environment variable: the publisher name on the certificate, as validated by Azure) | nothing; fails the run when a shipped executable is unsigned, untimestamped or signed by another publisher | | `packages` | stable only | `RELEASE_APP_PRIVATE_KEY` (private key of the release GitHub App, see below) plus the repository variable `RELEASE_APP_CLIENT_ID`, `CHOCO_API_KEY` (chocolatey.org) | the Chocolatey package pushed to chocolatey.org, and a `build(packaging): …` pull request against `main` with `bucket/gclo.json` and `packaging/chocolatey` pointed at the release | | `winget` | stable only, once the repository variable `WINGET_PACKAGE_ID` is set (`KofTwentyTwo.gclo`, after the one-time manual submission below) | `WINGET_TOKEN` (fine-grained token that can fork and open pull requests on public repositories, ≤ 90 days); missing → the job fails | a manifest update PR on [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) | | `nuget` | only when the repository variable `NUGET_PUBLISH` is `true` | `NUGET_API_KEY` (nuget.org, push rights for `gclo.Engine`) | `gclo.Engine` on nuget.org | @@ -80,6 +81,64 @@ requests **write**, no ruleset bypass. Store its client id as the Actions variab `RELEASE_APP_CLIENT_ID` and its private key (`.pem`) as `RELEASE_APP_PRIVATE_KEY` in the `release` environment; never as a repository secret or a committed file. +## Code signing + +Every release is Authenticode-signed with **Azure Artifact Signing** (formerly Trusted +Signing): the shared workflow signs every published `.exe`/`.dll` (app and CLI) before +packing, and `vpk pack` signs `Setup.exe` and the update stub with the same identity +(`--azureTrustedSignFile`). Always SHA-256 with an RFC 3161 timestamp from +`http://timestamp.acs.microsoft.com`. `SHA256SUMS`, the Velopack feed and the +attestations are produced after signing, so they describe the signed files. The +`verify-signatures` job then downloads what was published and runs +`signtool verify /pa /all` on Setup.exe and every gclo executable (ADR +[0008](adr/0008-azure-artifact-signing.md)). + +There is **no signing secret**: the release job logs in to Azure over GitHub OIDC +(`azure/login`) as an Entra application whose federated credential trusts only this +repository's `release` environment, and that application holds only the +*Artifact Signing Certificate Profile Signer* role on the certificate profile. +The identity is configuration, not a secret, so it lives in `release`-environment +variables: + +| Variable | Value | +| --- | --- | +| `AZURE_TENANT_ID` | the Entra tenant GUID (not the directory domain) | +| `AZURE_CLIENT_ID` | the application (client) id of `gclo-release-signing` | +| `AZURE_SUBSCRIPTION_ID` | the subscription that holds the signing account | +| `TRUSTED_SIGNING_ENDPOINT` | the account's regional endpoint, e.g. `https://eus.codesigning.azure.net` | +| `TRUSTED_SIGNING_ACCOUNT` | the signing account name (`kof22signing`) | +| `TRUSTED_SIGNING_PROFILE` | the certificate profile name (`releases`) | +| `TRUSTED_SIGNING_SUBJECT` | the validated publisher name on the certificate, checked by `verify-signatures` | + +A missing variable fails the Azure login, and the run with it; nothing unsigned is +published. The Azure side (application, federated credential, role assignment) is +administered interactively by the owner; `.github/scripts/Set-ReleaseSigning.ps1` +records the exact steps and is idempotent. + +### Signing locally + +Only for a one-off (for example re-signing a hotfix build by hand); releases are +signed in CI. You need SignTool (Windows SDK), the .NET 8 runtime, the +[Microsoft.ArtifactSigning.Client](https://www.nuget.org/packages/Microsoft.ArtifactSigning.Client) +package (the `dlib`), and an identity holding the *Certificate Profile Signer* role: + +```powershell +# One-time: the dlib, and a metadata file with the same values as the variables above +nuget install Microsoft.ArtifactSigning.Client -x -OutputDirectory $env:LOCALAPPDATA\ArtifactSigning +@{ Endpoint = 'https://eus.codesigning.azure.net'; CodeSigningAccountName = 'kof22signing'; CertificateProfileName = 'releases' } | + ConvertTo-Json | Set-Content $env:LOCALAPPDATA\ArtifactSigning\metadata.json + +# Sign in as an authorized identity (the dlib uses the Azure CLI / Azure PowerShell credential) +az login --tenant + +# Sign and verify +$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe | Where-Object FullName -match '\\x64\\' | Sort-Object FullName -Descending | Select-Object -First 1 +& $signtool sign /v /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 ` + /dlib "$env:LOCALAPPDATA\ArtifactSigning\Microsoft.ArtifactSigning.Client\bin\x64\Azure.CodeSigning.Dlib.dll" ` + /dmdf "$env:LOCALAPPDATA\ArtifactSigning\metadata.json" .\gclo.exe +& $signtool verify /pa /all /v .\gclo.exe +``` + ## First winget submission (one-time, manual) `wingetcreate update` can only update a package that already exists in @@ -166,6 +225,10 @@ Both install the same asset, `gclo-cli-win-x64-.zip`, by URL and SHA-25 `SHA256SUMS`, provenance, a **draft** GitHub Release with every asset and generated notes, then **publish** — releases are immutable, so everything lands while the release is still a draft. + - **Verify signatures on the published assets** — `signtool verify /pa /all` + on Setup.exe and every gclo executable inside the portable and CLI zips, + checking the publisher name and the timestamp. A failure here means a + published release is unsigned: handle it as "A broken release". - **Scoop and Chocolatey**, **winget**, **NuGet** — the publishers described above; each fails loudly when it cannot publish. diff --git a/docs/adr/0008-azure-artifact-signing.md b/docs/adr/0008-azure-artifact-signing.md new file mode 100644 index 0000000..1ef7db6 --- /dev/null +++ b/docs/adr/0008-azure-artifact-signing.md @@ -0,0 +1,60 @@ +# 0008. Authenticode-sign releases with Azure Artifact Signing over GitHub OIDC + +- Status: accepted +- Date: 2026-10-10 +- Supersedes: KofTwentyTwo standards exception EX-0002 for this repository + +## Context + +K22-REL-05 requires Windows executables and installers to be Authenticode-signed. +Until now gclo shipped unsigned (exception EX-0002, #57): users saw "Unknown +publisher" in SmartScreen and UAC, and the only origin check was the build-provenance +attestation, which proves which workflow built a file, not who publishes it. + +KofTwentyTwo now has an Azure Artifact Signing (formerly Trusted Signing) account +with a completed publisher identity validation and an active Public Trust +certificate profile. The standards' shared `release-velopack.yml` already carries +the signing steps behind its `sign` input: `azure/login` over OIDC, then +`azure/artifact-signing-action` over every published `.exe`/`.dll`, then `vpk pack +--azureTrustedSignFile` so Setup.exe and the update stub are signed with the same +identity. + +## Decision + +- Turn the shared workflow's `sign` input on. Signing happens after publish and + before packing; `SHA256SUMS`, the Velopack feed and the attestations are + computed over the signed files. SHA-256 digests and RFC 3161 timestamps + (`http://timestamp.acs.microsoft.com`) always. +- Authenticate with **GitHub OIDC**, never a secret: an Entra application + (`gclo-release-signing`) with a federated credential whose subject is + `repo:KofTwentyTwo/gclo:environment:release`, so only a job running in the + tag-restricted `release` environment can obtain a token. The application holds + exactly one role, *Artifact Signing Certificate Profile Signer*, scoped to the + certificate profile. Owner or Contributor on the subscription grants no signing + right and is not given. +- The identity is configuration, not a secret: tenant, client and subscription ids, + endpoint, account and profile names are `release`-environment variables. Missing + values fail the Azure login, and nothing unsigned is published. +- A caller-side `verify-signatures` job downloads the published Setup.exe, portable + and CLI zips and runs `signtool verify /pa /all` plus `Get-AuthenticodeSignature` + on every gclo executable, checking status, timestamp and the validated publisher + name (`TRUSTED_SIGNING_SUBJECT`). It cannot stop a publish that already happened, + but it turns a silently unsigned release into a failed run handled by the broken- + release runbook; the shared workflow is asked to verify before publishing + (standards#22 follow-up). +- Local signing is documented for one-off needs only (SignTool + the + `Microsoft.ArtifactSigning.Client` dlib + an authorized Azure sign-in); releases + are signed in CI. +- The Azure configuration is scripted in `.github/scripts/Set-ReleaseSigning.ps1` + (idempotent, run interactively by the owner) so it can be audited and repeated. + +## Consequences + +- SmartScreen and UAC show the validated publisher; Velopack's installer and update + stub are signed, so silent self-update no longer rests on TLS plus the GitHub + account alone. +- Releases depend on Azure being reachable and the certificate profile being + active; a signing outage fails the release rather than shipping unsigned. +- No PFX, private key or client secret exists anywhere; nothing to rotate except + the federated credential if the repository or environment is renamed. +- EX-0002 closes for gclo once the first signed release verifies. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 3ead58b..f85c40b 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -112,3 +112,4 @@ flowchart LR | 2026-10-09 | v1.0.1 | James Maes (@KofTwentyTwo) | Initial model, covering the path-recovery, WSL clone, vault scoping, package-manager, and SBOM changes of 1.0.1 | | 2026-10-09 | v1.0.1 | James Maes (@KofTwentyTwo) | T11 updated: releases build in the shared standards workflow; publishing tokens moved to the `release` environment (#76) | | 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T11: the packaging PR is opened with a short-lived release GitHub App token (contents + pull requests on this repository, no bypass) instead of a long-lived PAT; the commit is GitHub-signed (#85) | +| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T2/T11: releases are Authenticode-signed with Azure Artifact Signing over GitHub OIDC (no signing secret; federated credential bound to this repository's `release` environment; signer role scoped to the certificate profile) and the published assets are verified with `signtool verify /pa /all` (#57, ADR 0008) | -- 2.54.0 From 0f90992b62dad8decf7159d7d4d87d1c33bbb64b Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 10 Oct 2026 14:08:42 -0500 Subject: [PATCH 2/2] ci(release): read the validated publisher from the profile's active certificate The certificate profile resource carries the subject on its issued certificate (subjectName), not as a profile field; the script now derives TRUSTED_SIGNING_SUBJECT from the CN of the active certificate and trims the endpoint's trailing slash. Ran against the live account: see #93. Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Maes --- .github/scripts/Set-ReleaseSigning.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/scripts/Set-ReleaseSigning.ps1 b/.github/scripts/Set-ReleaseSigning.ps1 index eb797d1..ba84a7a 100644 --- a/.github/scripts/Set-ReleaseSigning.ps1 +++ b/.github/scripts/Set-ReleaseSigning.ps1 @@ -89,8 +89,14 @@ if($certProfile.properties.status -ne 'Active') { throw "Certificate profile '$ProfileName' is '$($certProfile.properties.status)', not Active." } -$endpoint = $account.properties.accountUri -$subjectName = $certProfile.properties.commonName +$endpoint = $account.properties.accountUri.TrimEnd('/') +# The validated publisher is on the issued certificate (subjectName), not on the profile. +$certificate = $certProfile.properties.certificates | Where-Object { $_.status -eq 'Active' } | Select-Object -First 1 +if($null -eq $certificate) +{ + throw "Certificate profile '$ProfileName' has no active certificate." +} +$subjectName = (($certificate.subjectName -split ',\s*') | Where-Object { $_ -like 'CN=*' } | Select-Object -First 1) -replace '^CN=' Write-Output "Signing account '$AccountName' in $($account.location): endpoint $endpoint." Write-Output "Profile '$ProfileName' is Active (type $($certProfile.properties.profileType)); certificate subject CN='$subjectName'." -- 2.54.0