|
| 1 | +# Builds a demo .NET exe, pulls signing inputs from Key Vault, |
| 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 | + # Set to 'true' if you want the workflow to create the certificate profile when missing. |
| 24 | + # Default is 'false' because identity validation + certificate profile creation are typically done in the Azure Portal. |
| 25 | + # If you set this to 'true', you must also provide the Identity validation Id (via Key Vault secret 'artifactSigningIdentityValidationId'). |
| 26 | + AUTO_CREATE_CERT_PROFILE: 'false' |
| 27 | + |
| 28 | + # Non-secret inputs (configure as GitHub repo variables or secrets): |
| 29 | + KEYVAULT_NAME: ${{ secrets.KEYVAULT_NAME }} |
| 30 | + ARTIFACT_SIGNING_RESOURCE_GROUP: ${{ secrets.ARTIFACT_SIGNING_RESOURCE_GROUP }} |
| 31 | + |
| 32 | + # Optional override; only needed when creating the cert profile: |
| 33 | + CERT_PROFILE_TYPE: PublicTrustTest |
| 34 | + |
| 35 | + steps: |
| 36 | + - name: Checkout |
| 37 | + uses: actions/checkout@v4 |
| 38 | + |
| 39 | + - name: Setup .NET |
| 40 | + uses: actions/setup-dotnet@v4 |
| 41 | + with: |
| 42 | + dotnet-version: '8.0.x' |
| 43 | + |
| 44 | + - name: Publish (unsigned) |
| 45 | + shell: pwsh |
| 46 | + run: | |
| 47 | + dotnet publish .\SigningDemo\SigningDemo.csproj -c $env:BUILD_CONFIGURATION -r $env:RUNTIME_IDENTIFIER --self-contained false |
| 48 | +
|
| 49 | + - name: Azure Login (OIDC) |
| 50 | + uses: azure/login@v2 |
| 51 | + with: |
| 52 | + client-id: ${{ secrets.AZURE_CLIENT_ID }} |
| 53 | + tenant-id: ${{ secrets.AZURE_TENANT_ID }} |
| 54 | + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} |
| 55 | + |
| 56 | + - name: Load signing variables from Key Vault |
| 57 | + shell: pwsh |
| 58 | + run: | |
| 59 | + $ErrorActionPreference = 'Stop' |
| 60 | +
|
| 61 | + if ([string]::IsNullOrWhiteSpace($env:KEYVAULT_NAME)) { throw "Missing KEYVAULT_NAME (set as a GitHub secret)." } |
| 62 | +
|
| 63 | + pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\load-signing-config-from-kv.ps1 |
| 64 | +
|
| 65 | + - name: Ensure certificate profile exists (optional) |
| 66 | + if: ${{ env.AUTO_CREATE_CERT_PROFILE == 'true' }} |
| 67 | + shell: pwsh |
| 68 | + run: | |
| 69 | + $ErrorActionPreference = 'Stop' |
| 70 | +
|
| 71 | + $rg = "$env:ARTIFACT_SIGNING_RESOURCE_GROUP".Trim() |
| 72 | + if ([string]::IsNullOrWhiteSpace($rg)) { throw "Missing ARTIFACT_SIGNING_RESOURCE_GROUP" } |
| 73 | +
|
| 74 | + $accountName = "$env:ARTIFACT_SIGNING_ACCOUNT_NAME".Trim() |
| 75 | + $profileName = "$env:ARTIFACT_SIGNING_CERT_PROFILE_NAME".Trim() |
| 76 | + $profileType = "$env:CERT_PROFILE_TYPE".Trim() |
| 77 | + if ([string]::IsNullOrWhiteSpace($profileType)) { $profileType = 'PublicTrust' } |
| 78 | +
|
| 79 | + if ([string]::IsNullOrWhiteSpace($accountName)) { throw "Missing ARTIFACT_SIGNING_ACCOUNT_NAME" } |
| 80 | + if ([string]::IsNullOrWhiteSpace($profileName)) { throw "Missing ARTIFACT_SIGNING_CERT_PROFILE_NAME" } |
| 81 | +
|
| 82 | + $identityValidationId = "$env:ARTIFACT_SIGNING_IDENTITY_VALIDATION_ID".Trim() |
| 83 | + if ([string]::IsNullOrWhiteSpace($identityValidationId)) { |
| 84 | + throw "AUTO_CREATE_CERT_PROFILE=true but Key Vault secret 'artifactSigningIdentityValidationId' is empty. Complete identity validation in the portal and set that secret." |
| 85 | + } |
| 86 | +
|
| 87 | + $subId = (az account show --query id -o tsv) |
| 88 | + if ([string]::IsNullOrWhiteSpace($subId)) { throw "Unable to determine subscription id from Azure CLI context." } |
| 89 | +
|
| 90 | + $profileResourceId = "/subscriptions/$subId/resourceGroups/$rg/providers/Microsoft.CodeSigning/codeSigningAccounts/$accountName/certificateProfiles/$profileName" |
| 91 | + $profileUrl = "https://management.azure.com$profileResourceId?api-version=2025-10-13" |
| 92 | +
|
| 93 | + Write-Host "Checking certificate profile: $profileResourceId" |
| 94 | +
|
| 95 | + az rest --method get --url $profileUrl --only-show-errors | Out-Null |
| 96 | + $getExit = $LASTEXITCODE |
| 97 | +
|
| 98 | + if ($getExit -eq 0) { |
| 99 | + Write-Host "Certificate profile exists: $profileName" |
| 100 | + exit 0 |
| 101 | + } |
| 102 | +
|
| 103 | + Write-Host "Certificate profile missing (or not readable). Creating it..." |
| 104 | +
|
| 105 | + $body = @{ |
| 106 | + properties = @{ |
| 107 | + identityValidationId = $identityValidationId |
| 108 | + profileType = $profileType |
| 109 | + includeStreetAddress = $false |
| 110 | + includePostalCode = $false |
| 111 | + } |
| 112 | + } | ConvertTo-Json -Depth 10 |
| 113 | +
|
| 114 | + az rest --method put --url $profileUrl --body $body --only-show-errors | Out-Null |
| 115 | + if ($LASTEXITCODE -ne 0) { throw "Failed to create certificate profile via ARM. Ensure the GitHub identity has RG Contributor (or equivalent) permissions." } |
| 116 | +
|
| 117 | + Write-Host "Created certificate profile: $profileName" |
| 118 | +
|
| 119 | + - name: Sign with Azure Artifact Signing (SignTool + dlib) |
| 120 | + shell: pwsh |
| 121 | + run: | |
| 122 | + $ErrorActionPreference = 'Stop' |
| 123 | +
|
| 124 | + $tempRoot = Join-Path "$env:RUNNER_TEMP" "artifact-signing" |
| 125 | + New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null |
| 126 | +
|
| 127 | + $nugetExe = Join-Path $tempRoot "nuget.exe" |
| 128 | + Invoke-WebRequest -Uri https://dist.nuget.org/win-x86-commandline/latest/nuget.exe -OutFile $nugetExe |
| 129 | +
|
| 130 | + $sdkOut = Join-Path $tempRoot "sdk" |
| 131 | + & $nugetExe install Microsoft.Windows.SDK.BuildTools -x -OutputDirectory $sdkOut | Out-Host |
| 132 | +
|
| 133 | + $signtool = Get-ChildItem -Path $sdkOut -Recurse -Filter signtool.exe | Where-Object { $_.FullName -match "\\x64\\signtool\\.exe$" } | Select-Object -First 1 |
| 134 | + if (-not $signtool) { |
| 135 | + $signtool = Get-ChildItem -Path $sdkOut -Recurse -Filter signtool.exe | Where-Object { $_.FullName -match "\\x64\\signtool\\.exe" } | Select-Object -First 1 |
| 136 | + } |
| 137 | + if (-not $signtool) { throw "signtool.exe not found after extracting Microsoft.Windows.SDK.BuildTools" } |
| 138 | +
|
| 139 | + $dlibOut = Join-Path $tempRoot "dlib" |
| 140 | + & $nugetExe install Microsoft.ArtifactSigning.Client -x -OutputDirectory $dlibOut | Out-Host |
| 141 | +
|
| 142 | + $dlib = Get-ChildItem -Path $dlibOut -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match "\\x64\\Azure\\.CodeSigning\\.Dlib\\.dll$" } | Select-Object -First 1 |
| 143 | + if (-not $dlib) { |
| 144 | + $dlib = Get-ChildItem -Path $dlibOut -Recurse -Filter Azure.CodeSigning.Dlib.dll | Select-Object -First 1 |
| 145 | + } |
| 146 | + if (-not $dlib) { throw "Azure.CodeSigning.Dlib.dll not found after extracting Microsoft.ArtifactSigning.Client" } |
| 147 | +
|
| 148 | + $metadataPath = Join-Path $tempRoot "metadata.json" |
| 149 | + $metadata = @{ |
| 150 | + Endpoint = "$env:ARTIFACT_SIGNING_ENDPOINT" |
| 151 | + CodeSigningAccountName = "$env:ARTIFACT_SIGNING_ACCOUNT_NAME" |
| 152 | + CertificateProfileName = "$env:ARTIFACT_SIGNING_CERT_PROFILE_NAME" |
| 153 | + CorrelationId = "gha-${{ github.run_id }}" |
| 154 | + } | ConvertTo-Json -Depth 4 |
| 155 | +
|
| 156 | + $metadata | Out-File -FilePath $metadataPath -Encoding utf8 |
| 157 | +
|
| 158 | + $unsigned = "$env:GITHUB_WORKSPACE\SigningDemo\bin\$env:BUILD_CONFIGURATION\net8.0\$env:RUNTIME_IDENTIFIER\publish\SigningDemo.exe" |
| 159 | + if (-not (Test-Path $unsigned)) { throw "Unsigned binary not found at $unsigned" } |
| 160 | +
|
| 161 | + $signedDir = "$env:GITHUB_WORKSPACE\artifacts" |
| 162 | + New-Item -ItemType Directory -Force -Path $signedDir | Out-Null |
| 163 | + $signed = Join-Path $signedDir "SigningDemo.signed.exe" |
| 164 | + Copy-Item $unsigned $signed -Force |
| 165 | +
|
| 166 | + Write-Host "Using signtool: $($signtool.FullName)" |
| 167 | + Write-Host "Using dlib: $($dlib.FullName)" |
| 168 | +
|
| 169 | + & $signtool.FullName sign /v /debug /fd SHA256 /tr "http://timestamp.acs.microsoft.com" /td SHA256 /dlib "$($dlib.FullName)" /dmdf "$metadataPath" "$signed" |
| 170 | +
|
| 171 | + - name: Verify signature |
| 172 | + shell: pwsh |
| 173 | + run: | |
| 174 | + $ErrorActionPreference = 'Stop' |
| 175 | + $signed = "$env:GITHUB_WORKSPACE\artifacts\SigningDemo.signed.exe" |
| 176 | + Get-AuthenticodeSignature $signed | Format-List |
| 177 | +
|
| 178 | + - name: Upload signed artifact |
| 179 | + uses: actions/upload-artifact@v4 |
| 180 | + with: |
| 181 | + name: signed-drop |
| 182 | + path: artifacts\ |
0 commit comments