Signing NuGet Packages Using Azure DevOps and Workload Identity Federation

Yak-Shaver's Delight

Azure released a major update to some of their VM images last week and it’s caused a number of problems for me:

  1. mono support was removed from ubuntu-latest, which caused all of our FAKE v4.0 builds to no longer work1 for Akka.NET and several of our other mature projects;
  2. SignService, our workhorse for Authenticode signing all Petabridge NuGet packages for the past seven years, stopped working suddenly on the Azure App Service we’ve been using.

I have no idea what Microsoft did to kill this service off, but my guess is they finally stopped supporting the ancient version of .NET Framework this was running on starting on April 11th or so:

Rest in peace, SignService

We had owed a customer an update today and the race was on to find a replacement for SignClient - quickly. We quickly settled upon dotnet/sign - and then the race was on to figure out how to solve the infernal quagmire of Azure Entra / AAD permissions hell in order to access our Azure Key Vault where our signing certificate is stored.

This post explains how to do that.

No More Service Principal Credential Rotation Chores, Please

I have a healthy respect for network security, but one of the things I absolutely cannot stand about working with Azure is the Byzantine complexity of Azure service principals, app registrations, and certificate / secret expirations.

We have several services that we deploy to very, very rarely - SignService being one of those ones that we touch maybe once every 2-3 years when our signing certificates have to get rotated.

The last thing I want to be bothered with is figuring out which automatically-named principal expired, how to rotate its secret, and what all of the knock-on effects are.

Enter “managed identities” - service principals that rotate their own credentials, but best of all: they’re an actual first class Azure resource so you can find them easily inside a resource group.

So to create a managed identity with the appropriate Key Vault permissions that dotnet sign needs, I created a PowerShell script:

<#
.SYNOPSIS
    Creates a managed identity and grants it read-only access to a specified Azure Key Vault.

.DESCRIPTION
    This script performs the following actions:
      - Logs in to Azure.
      - Creates a managed identity (default name "CodeSigner", configurable via -ManagedIdentityName)
        in the specified resource group (default "ResourceGroupWithVaults", configurable via -ResourceGroup).
      - Retrieves the principal ID of the created managed identity.
      - Configures the Key Vault access policy to allow the managed identity to:
           Perform cryptographic signing and key management "get" operations on keys 
            (note: "get" here retrieves the public key, not the private key).
           Retrieve certificates ("get" permission).

.PARAMETER KeyVaultName
    The name of the existing Azure Key Vault on which the access policy will be set.

.PARAMETER ManagedIdentityName
    (Optional) The name for the managed identity. Defaults to "CodeSigner".

.PARAMETER ResourceGroup
    (Optional) The resource group in which the managed identity will be created. 
    Defaults to "ResourceGroupWithVaults".

.EXAMPLE
    .\Setup-ManagedIdentity.ps1 -KeyVaultName "MyExistingKeyVault"

.EXAMPLE
    .\Setup-ManagedIdentity.ps1 -KeyVaultName "MyExistingKeyVault" -ManagedIdentityName "MyCustomIdentity" -ResourceGroup "MyResourceGroup"
#>

param (
    [Parameter(Mandatory = $true)]
    [string]$KeyVaultName,

    [Parameter(Mandatory = $false)]
    [string]$ResourceGroup = "ResourceGroupWithVaults",

    [Parameter(Mandatory = $false)]
    [string]$ManagedIdentityName = "CodeSigner"
)

# Log in to Azure
Write-Output "Logging into Azure..."
az login

# Create the managed identity
Write-Output "Creating Managed Identity '$ManagedIdentityName' in Resource Group '$ResourceGroup'..."
$identityCreationResult = az identity create --name $ManagedIdentityName --resource-group $ResourceGroup | ConvertFrom-Json

# Retrieve the managed identity's principal ID
$principalId = $identityCreationResult.principalId
Write-Output "Managed Identity Principal ID: $principalId"

# Set the access policy on the specified Key Vault with the desired permissions:
# - For keys: Allow the 'sign' operation and 'get' operation (public key only)
# - For certificates: Allow the 'get' operation.
Write-Output "Setting Key Vault access policy on '$KeyVaultName'..."
az keyvault set-policy --name $KeyVaultName `
    --object-id $principalId `
    --key-permissions sign get `
    --certificate-permissions get

Write-Output "Managed Identity '$ManagedIdentityName' has been created and granted the required access on Key Vault '$KeyVaultName' in Resource Group '$ResourceGroup'."

Worth noting: I’m still using the classic “access policy” configuration with Azure Key Vaults because this vault is quite old. You can easily swap that code out with the more modern role-based access control (RBAC) instead.

Workload Identity Federations and Managed Identities

The dotnet sign sample workflows for GitHub Actions and Azure DevOps are a bit out of date so I had to do some trail-blazing here.

My goal: I don’t want to have to update any permissions / service principals / whatever with this service unless I’m rotating the certificate itself. I don’t want to store any secrets in Azure DevOps.

The best candidate for this is an Entra workload identity federation; this allows me to establish a long-term “trust” between Azure DevOps (or GitHub Actions) and my managed identity.

I could use an Azure DevOps service connection to create the workload identity federation for me, and I followed Bjorn Peters’ advice here on how to do that: “Configure workload identity federation in Azure DevOps

After finagling with the Azure DevOps service connection a bit, I got that working - and now for the great unanswered question: how do I actually get dotnet sign to use any of these credentials?

Azure DevOps YAML

Welp, the first thing we need to do is to install dotnet sign as a local dotnet tool:

dotnet new tool-manifest
dotnet tool install --local sign --version 0.9.1-beta.25181.2 

This will create a .config/dotnet-tools.json file in the root of your project:

{
  "version": 1,
  "isRoot": true,
  "tools": {
    "sign": {
      "version": "0.9.1-beta.25181.2",
      "commands": [
        "sign"
      ],
      "rollForward": false
    }
  }
}

From there, we are going to create a small Azure DevOps YAML pipeline using our service connection from earlier:

pool:
  vmImage: windows-latest
  demands: Cmd

trigger:
  branches:
    include:
      - refs/tags/*

pr: none

variables:
  - group: signingSecrets #create this group with SECRET variables `signingUsername` and `signingPassword`
  - name: artifactName
    value: 'signedPackages'
  - name: nugetPackagesDir
    value: '$(System.DefaultWorkingDirectory)/bin/nuget' # Default output location for build.ps1
  - name: codeSigningConnectionName
    value: 'CodeSigning'

stages:
- stage: Release
  displayName: 'Build, Sign, and Publish NuGet Packages'
  jobs:
  - job: BuildAndSign
    displayName: 'Build and Sign NuGet Packages'
    steps:
    - task: UseDotNet@2
      displayName: 'Use .NET SDK'
      inputs:
        packageType: sdk
        useGlobalJson: true

    - script: 'dotnet tool restore'
      failOnStderr: true
      displayName: 'Restore dotnet tools'

    - task: PowerShell@2
      displayName: 'Build and Create NuGet Packages'
      inputs:
        filePath: './build.ps1'
        arguments: 'CreateNuget -SkipIncrementalist'

    - task: AzureCLI@2
      displayName: 'Sign NuGet Packages'
      inputs:
        azureSubscription: $(codeSigningConnectionName)
        scriptType: pscore
        scriptLocation: inlineScript
        inlineScript: |
          dotnet sign code azure-key-vault `
          "**/*.nupkg" `
          --base-directory "$(nugetPackagesDir)" `
          --publisher-name "Contoso" `
          --description "Contoso Tools and Drivers" `
          --description-url "https://contoso.com/" `
          --azure-key-vault-certificate "$(CertName)" `
          --azure-key-vault-url "$(VaultUri)" `
          -v Information

    - task: PublishPipelineArtifact@1
      displayName: 'Publish Signed Packages Artifact'
      inputs:
        targetPath: '$(nugetPackagesDir)'
        artifact: '$(artifactName)'

The magic trick here is running dotnet sign inside the AzureCLI@2 step - this will help ensure that the DefaultAzureCredential used by dotnet sign gets populated with the correct credentials belonging to your managed identity.

Conclusions

If it sounds like I’m complaining about FAKE 4 or SignClient - while I am annoyed that this got dropped on my doorstep all at once, I’m also quite pleased that these build systems ran in production for nearly a decade with very little modification during that time. That is one hell of a good run in my eyes.

Hopefully we’ll get another decade or so out of dotnet sign.

  1. FAKE is actually great. 

Discussion, links, and tweets

I'm the CTO and founder of Petabridge, where I'm making distributed programming for .NET developers easy by working on Akka.NET, Phobos, and more..