Background

It is becoming increasingly important to sign files digitally to ensure that they have not been tampered with, to secure the software supply chain. This is something we have done for a good while as a step in our Azure DevOps pipelines. However, recent(ish) changes in the way certificates are issued has meant we have had to revise our approach.

The Problem

We used to use a .PFX file, stored as an Azure DevOps secure file, that contained the public and private keys and was accessed using a password, to sign our files.

However, when we renewed our code signing certificate with DigiCert we found this approach no longer valid due to new private key storage requirements for Code Signing certificates.

The basic issue is that now when we sign a file, the signing tool needs to make a call back to a secure location to validate the certificate. In our case Digicert’s Keylocker service.

The Solution

This change required some changes to our pipelines.

  1. Store our .CRT certificate file as a secure file in Azure DevOps
  2. Store our .p12 signer’s certificate file as a secure file in Azure DevOps
  3. Store our DigiCert account settings as Azure DevOps pipeline variables (a mixture of standard and secret ones)
  4. Update our pipeline as follows
- task: SSMClientToolsSetup@1
  displayName: Install DigiCert Client Tools 

- task: DownloadSecureFile@1
  displayName: Download DigiCert Code Signing Certificate File
  inputs:
    secureFile: 'DigicertCodeSigningCert.crt'

- task: DownloadSecureFile@1
  displayName: Download DigiCert Signer Certificate File
  inputs:
    secureFile: 'DigiCertSignerCertificate.p12'

- task: PowerShell@2
  displayName: Code Sign Files
  inputs:
    targetType: 'inline'
    script: |
      # Define the base path where signtool.exe is located
      $basePath = "C:\Program Files (x86)\Windows Kits\10\bin"
      # Filtering via version and architecture, could use just one of the these, depends on needs
      $preferredVersion = "x64"

      # Get the matching signtool path (pick the last in the list if multiple returned)
      $signtoolPath = (Get-ChildItem -Path $basePath -Recurse -Filter "signtool.exe" -File | Where-Object { $_.FullName -like "*\$preferredVersion\*" })[-1] | Select-Object -ExpandProperty FullName

      # Find the files that match filter that need to be signed
      write-host "Finding files to sign"
      Get-ChildItem -Path "$(Build.SourcesDirectory)/myproject/**/*.exe" | ForEach-Object {
         $filePath = $_.FullName
         write-host "Signing file $filePath"
         & $signtoolPath sign /v /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 /csp "DigiCert Signing Manager KSP" /kc "$(SM_KEYPAIR_ALIAS)" /f "$(Agent.TempDirectory)\DigicertCodeSigningCert.crt" $filePath
       }         
   env:
     SM_HOST: $(SM_HOST) 
     SM_API_KEY: $(SM_API_KEY) 
     SM_CLIENT_CERT_PASSWORD: $(SM_CLIENT_CERT_PASSWORD) 
     SM_CLIENT_CERT_FILE: $(Agent.TempDirectory)\DigiCert Signer Certificate_pkcs12.p12
     SM_TLS_SKIP_VERIFY: $(SM_TLS_SKIP_VERIFY)

The gotcha with variables

I have blogged a number of times before about the need to be careful with the syntax for Azure DevOps variables. Guess what, I got caught out by this again!

I had initially used the ${{ variables.XXX }} format for injecting the Azure DevOps variables as PowerShell environment variables e.g.

 SM_HOST: ${{ variables.SM_HOST }}
 SM_API_KEY: ${{ variables.SM_API_KEY }}

This worked fine for the standard variables, but not for the secret ones. The secret ones were not being injected into the script as environment variables so when we tried to sign a file we got the error

SignTool Error: No private key is available.

This was a case of trying to be too clever with the syntax, the correct syntax in this case is to use the standard macro syntax $(XXX) format for all variables, especially the secret ones.

 SM_HOST: $(SM_HOST)
 SM_API_KEY: $(SM_API_KEY)

Once this was done, the signing process worked as expected.