feat(release): Authenticode-sign releases with Azure Artifact Signing over OIDC #93
No files matched your search
@@ -0,0 +1,159 @@
|
||||
# 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:<owner/repo>:environment:<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.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. 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'"
|
||||
@@ -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
|
||||
|
||||
@@ -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-<version>.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
|
||||
|
||||
|
||||
+7
-5
@@ -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.<channel>.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").
|
||||
+64
-1
@@ -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 <tenant GUID>
|
||||
|
||||
# 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-<version>.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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
Reference in new issue
Block a user