|
| 1 | +# Builds a demo .NET exe, pulls signing inputs from Key Vault, ensures the cert profile exists, |
| 2 | +# then signs and verifies the binary using SignTool + the Azure Artifact Signing dlib. |
| 3 | + |
| 4 | +name: Artifact Signing Demo |
| 5 | + |
| 6 | +on: |
| 7 | + push: |
| 8 | + branches: [ main ] |
| 9 | + workflow_dispatch: {} |
| 10 | + |
| 11 | +permissions: |
| 12 | + contents: read |
| 13 | + id-token: write |
| 14 | + |
| 15 | +jobs: |
| 16 | + sign: |
| 17 | + runs-on: windows-latest |
| 18 | + |
| 19 | + env: |
| 20 | + BUILD_CONFIGURATION: Release |
| 21 | + RUNTIME_IDENTIFIER: win-x64 |
| 22 | + |
| 23 | + # Non-secret inputs (configure as GitHub repo variables or secrets): |
| 24 | + KEYVAULT_NAME: ${{ secrets.KEYVAULT_NAME }} |
| 25 | + ARTIFACT_SIGNING_RESOURCE_GROUP: ${{ secrets.ARTIFACT_SIGNING_RESOURCE_GROUP }} |
| 26 | + |
| 27 | + # Optional override; only needed when creating the cert profile: |
| 28 | + CERT_PROFILE_TYPE: PublicTrust |
| 29 | + |
| 30 | + steps: |
| 31 | + - name: Checkout |
| 32 | + uses: actions/checkout@v4 |
| 33 | + |
| 34 | + - name: Setup .NET |
| 35 | + uses: actions/setup-dotnet@v4 |
| 36 | + with: |
| 37 | + dotnet-version: '8.0.x' |
| 38 | + |
| 39 | + - name: Publish (unsigned) |
| 40 | + shell: pwsh |
| 41 | + run: | |
| 42 | + dotnet publish .\SigningDemo\SigningDemo.csproj -c $env:BUILD_CONFIGURATION -r $env:RUNTIME_IDENTIFIER --self-contained false |
| 43 | +
|
| 44 | + - name: Azure Login (OIDC) |
| 45 | + uses: azure/login@v2 |
| 46 | + with: |
| 47 | + client-id: ${{ secrets.AZURE_CLIENT_ID }} |
| 48 | + tenant-id: ${{ secrets.AZURE_TENANT_ID }} |
| 49 | + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} |
| 50 | + |
| 51 | + - name: Load signing variables from Key Vault |
| 52 | + shell: pwsh |
| 53 | + run: | |
| 54 | + $ErrorActionPreference = 'Stop' |
| 55 | +
|
| 56 | + if ([string]::IsNullOrWhiteSpace($env:KEYVAULT_NAME)) { throw "Missing KEYVAULT_NAME (set as a GitHub secret)." } |
| 57 | + if ([string]::IsNullOrWhiteSpace($env:ARTIFACT_SIGNING_RESOURCE_GROUP)) { throw "Missing ARTIFACT_SIGNING_RESOURCE_GROUP (set as a GitHub secret)." } |
| 58 | +
|
| 59 | + function Get-KvSecret([string]$name) { |
| 60 | + $value = az keyvault secret show --vault-name $env:KEYVAULT_NAME --name $name --query value -o tsv |
| 61 | + if ($LASTEXITCODE -ne 0) { throw "Failed to read Key Vault secret '$name' from vault '$($env:KEYVAULT_NAME)'" } |
| 62 | + if ($null -eq $value) { return '' } |
| 63 | + return "$value".Trim() |
| 64 | + } |
| 65 | +
|
| 66 | + $endpoint = Get-KvSecret 'artifactSigningEndpoint' |
| 67 | + $account = Get-KvSecret 'artifactSigningAccountName' |
| 68 | + $profile = Get-KvSecret 'artifactSigningCertificateProfileName' |
| 69 | + $idvId = Get-KvSecret 'artifactSigningIdentityValidationId' |
| 70 | +
|
| 71 | + if ([string]::IsNullOrWhiteSpace($endpoint)) { throw "Key Vault secret artifactSigningEndpoint is empty." } |
| 72 | + if ([string]::IsNullOrWhiteSpace($account)) { throw "Key Vault secret artifactSigningAccountName is empty." } |
| 73 | + if ([string]::IsNullOrWhiteSpace($profile)) { throw "Key Vault secret artifactSigningCertificateProfileName is empty." } |
| 74 | +
|
| 75 | + # Mask sensitive-ish values in logs. |
| 76 | + Write-Host "::add-mask::$endpoint" |
| 77 | + Write-Host "::add-mask::$account" |
| 78 | + Write-Host "::add-mask::$profile" |
| 79 | + if (-not [string]::IsNullOrWhiteSpace($idvId)) { Write-Host "::add-mask::$idvId" } |
| 80 | +
|
| 81 | + "ARTIFACT_SIGNING_ENDPOINT=$endpoint" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 |
| 82 | + "ARTIFACT_SIGNING_ACCOUNT_NAME=$account" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 |
| 83 | + "ARTIFACT_SIGNING_CERT_PROFILE_NAME=$profile" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 |
| 84 | + "ARTIFACT_SIGNING_IDENTITY_VALIDATION_ID=$idvId" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 |
| 85 | +
|
| 86 | + - name: Ensure certificate profile exists |
| 87 | + shell: pwsh |
| 88 | + run: | |
| 89 | + $ErrorActionPreference = 'Stop' |
| 90 | +
|
| 91 | + $rg = "$env:ARTIFACT_SIGNING_RESOURCE_GROUP".Trim() |
| 92 | + if ([string]::IsNullOrWhiteSpace($rg)) { throw "Missing ARTIFACT_SIGNING_RESOURCE_GROUP" } |
| 93 | +
|
| 94 | + $endpoint = "$env:ARTIFACT_SIGNING_ENDPOINT".Trim() |
| 95 | + $accountName = "$env:ARTIFACT_SIGNING_ACCOUNT_NAME".Trim() |
| 96 | + $profileName = "$env:ARTIFACT_SIGNING_CERT_PROFILE_NAME".Trim() |
| 97 | + $profileType = "$env:CERT_PROFILE_TYPE".Trim() |
| 98 | + if ([string]::IsNullOrWhiteSpace($profileType)) { $profileType = 'PublicTrust' } |
| 99 | +
|
| 100 | + if ([string]::IsNullOrWhiteSpace($endpoint)) { throw "Missing ARTIFACT_SIGNING_ENDPOINT" } |
| 101 | + if ([string]::IsNullOrWhiteSpace($accountName)) { throw "Missing ARTIFACT_SIGNING_ACCOUNT_NAME" } |
| 102 | + if ([string]::IsNullOrWhiteSpace($profileName)) { throw "Missing ARTIFACT_SIGNING_CERT_PROFILE_NAME" } |
| 103 | +
|
| 104 | + $identityValidationId = "$env:ARTIFACT_SIGNING_IDENTITY_VALIDATION_ID".Trim() |
| 105 | +
|
| 106 | + $subId = (az account show --query id -o tsv) |
| 107 | + if ([string]::IsNullOrWhiteSpace($subId)) { throw "Unable to determine subscription id from Azure CLI context." } |
| 108 | +
|
| 109 | + $profileResourceId = "/subscriptions/$subId/resourceGroups/$rg/providers/Microsoft.CodeSigning/codeSigningAccounts/$accountName/certificateProfiles/$profileName" |
| 110 | + $profileUrl = "https://management.azure.com$profileResourceId?api-version=2025-10-13" |
| 111 | +
|
| 112 | + Write-Host "Ensuring certificate profile exists: $profileResourceId" |
| 113 | +
|
| 114 | + $profileExists = $false |
| 115 | + try { |
| 116 | + az rest --method get --url $profileUrl --only-show-errors | Out-Null |
| 117 | + $profileExists = $true |
| 118 | + } catch { |
| 119 | + $profileExists = $false |
| 120 | + } |
| 121 | +
|
| 122 | + if (-not $profileExists) { |
| 123 | + if ([string]::IsNullOrWhiteSpace($identityValidationId)) { |
| 124 | + throw "Certificate profile is missing. Complete identity validation in the portal and set Key Vault secret 'artifactSigningIdentityValidationId'." |
| 125 | + } |
| 126 | +
|
| 127 | + $body = @{ |
| 128 | + properties = @{ |
| 129 | + identityValidationId = $identityValidationId |
| 130 | + profileType = $profileType |
| 131 | + includeStreetAddress = $false |
| 132 | + includePostalCode = $false |
| 133 | + } |
| 134 | + } | ConvertTo-Json -Depth 10 |
| 135 | +
|
| 136 | + az rest --method put --url $profileUrl --body $body --only-show-errors | Out-Null |
| 137 | + Write-Host "Created certificate profile: $profileName" |
| 138 | + } |
| 139 | +
|
| 140 | + - name: Sign with Azure Artifact Signing (SignTool + dlib) |
| 141 | + shell: pwsh |
| 142 | + run: | |
| 143 | + $ErrorActionPreference = 'Stop' |
| 144 | +
|
| 145 | + $tempRoot = Join-Path "$env:RUNNER_TEMP" "artifact-signing" |
| 146 | + New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null |
| 147 | +
|
| 148 | + $nugetExe = Join-Path $tempRoot "nuget.exe" |
| 149 | + Invoke-WebRequest -Uri https://dist.nuget.org/win-x86-commandline/latest/nuget.exe -OutFile $nugetExe |
| 150 | +
|
| 151 | + $sdkOut = Join-Path $tempRoot "sdk" |
| 152 | + & $nugetExe install Microsoft.Windows.SDK.BuildTools -x -OutputDirectory $sdkOut | Out-Host |
| 153 | +
|
| 154 | + $signtool = Get-ChildItem -Path $sdkOut -Recurse -Filter signtool.exe | Where-Object { $_.FullName -match "\\x64\\signtool\\.exe$" } | Select-Object -First 1 |
| 155 | + if (-not $signtool) { |
| 156 | + $signtool = Get-ChildItem -Path $sdkOut -Recurse -Filter signtool.exe | Where-Object { $_.FullName -match "\\x64\\signtool\\.exe" } | Select-Object -First 1 |
| 157 | + } |
| 158 | + if (-not $signtool) { throw "signtool.exe not found after extracting Microsoft.Windows.SDK.BuildTools" } |
| 159 | +
|
| 160 | + $dlibOut = Join-Path $tempRoot "dlib" |
| 161 | + & $nugetExe install Microsoft.ArtifactSigning.Client -x -OutputDirectory $dlibOut | Out-Host |
| 162 | +
|
| 163 | + $dlib = Get-ChildItem -Path $dlibOut -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match "\\x64\\Azure\\.CodeSigning\\.Dlib\\.dll$" } | Select-Object -First 1 |
| 164 | + if (-not $dlib) { |
| 165 | + $dlib = Get-ChildItem -Path $dlibOut -Recurse -Filter Azure.CodeSigning.Dlib.dll | Select-Object -First 1 |
| 166 | + } |
| 167 | + if (-not $dlib) { throw "Azure.CodeSigning.Dlib.dll not found after extracting Microsoft.ArtifactSigning.Client" } |
| 168 | +
|
| 169 | + $metadataPath = Join-Path $tempRoot "metadata.json" |
| 170 | + $metadata = @{ |
| 171 | + Endpoint = "$env:ARTIFACT_SIGNING_ENDPOINT" |
| 172 | + CodeSigningAccountName = "$env:ARTIFACT_SIGNING_ACCOUNT_NAME" |
| 173 | + CertificateProfileName = "$env:ARTIFACT_SIGNING_CERT_PROFILE_NAME" |
| 174 | + CorrelationId = "gha-${{ github.run_id }}" |
| 175 | + } | ConvertTo-Json -Depth 4 |
| 176 | +
|
| 177 | + $metadata | Out-File -FilePath $metadataPath -Encoding utf8 |
| 178 | +
|
| 179 | + $unsigned = "$env:GITHUB_WORKSPACE\SigningDemo\bin\$env:BUILD_CONFIGURATION\net8.0\$env:RUNTIME_IDENTIFIER\publish\SigningDemo.exe" |
| 180 | + if (-not (Test-Path $unsigned)) { throw "Unsigned binary not found at $unsigned" } |
| 181 | +
|
| 182 | + $signedDir = "$env:GITHUB_WORKSPACE\artifacts" |
| 183 | + New-Item -ItemType Directory -Force -Path $signedDir | Out-Null |
| 184 | + $signed = Join-Path $signedDir "SigningDemo.signed.exe" |
| 185 | + Copy-Item $unsigned $signed -Force |
| 186 | +
|
| 187 | + Write-Host "Using signtool: $($signtool.FullName)" |
| 188 | + Write-Host "Using dlib: $($dlib.FullName)" |
| 189 | +
|
| 190 | + & $signtool.FullName sign /v /debug /fd SHA256 /tr "http://timestamp.acs.microsoft.com" /td SHA256 /dlib "$($dlib.FullName)" /dmdf "$metadataPath" "$signed" |
| 191 | +
|
| 192 | + - name: Verify signature |
| 193 | + shell: pwsh |
| 194 | + run: | |
| 195 | + $ErrorActionPreference = 'Stop' |
| 196 | + $signed = "$env:GITHUB_WORKSPACE\artifacts\SigningDemo.signed.exe" |
| 197 | + Get-AuthenticodeSignature $signed | Format-List |
| 198 | +
|
| 199 | + - name: Upload signed artifact |
| 200 | + uses: actions/upload-artifact@v4 |
| 201 | + with: |
| 202 | + name: signed-drop |
| 203 | + path: artifacts\ |
0 commit comments