Code Signing with Certificates in CI/CD: A Developer’s Guide

In modern CI/CD pipelines, signing executables and libraries is a critical step for security and integrity. This guide summarizes essential knowledge for developers setting up software signing with certificates, particularly in enterprise environments using gMSA accounts and Windows Server Core.


🔐 1. Signing Certificates in the Pipeline

If you’re building software via Azure DevOps and wish to sign your output (e.g., .exe, .dll), you need:

  • A valid Code Signing certificate (.pfx)
  • The corresponding password
  • A signing user context (e.g., gMSA account)
  • Access to a timestamp server for trusted long-term signatures

Where to install the certificate?

Certificates are installed into the LocalMachine\My store — this is the correct location for certificates used by services or system-wide tasks.

The “My” store refers to “Personal” certificates in the context of the machine.

What to sign?

To gain full benefits (e.g., SmartScreen, AV trust, preventing tampering), sign:

  • All .exe files (client and server)
  • All your own .dll libraries

Avoid signing third-party libraries (e.g., from NuGet) — you are not the publisher and should not modify them.


🕒 2. Timestamping: Why and How

Purpose

A timestamp proves that the code was signed when the certificate was valid, even if the certificate expires later.

Typical command (via SignTool)

1
signtool sign /fd sha256 /tr http://timestamp.digicert.com /td sha256 /f yourcert.pfx /p yourpassword yourapp.exe
  • /fd sha256: Digest algorithm for the file
  • /td sha256: Digest algorithm for the timestamp
  • /tr: RFC 3161-compliant timestamp server

Note: The Digicert URL must be accessible over the internet.


🏛️ 3. Using Certificates with gMSA Accounts

If your DevOps agent runs as a gMSA (Group Managed Service Account), you must ensure the certificate is:

  • Installed in LocalMachine\My
  • Its private key is accessible by the gMSA account

Certificate location after import:

%ProgramData%\Microsoft\Crypto\RSA\MachineKeys — this is where private key material is stored.

The key file permissions must allow access for the gMSA (e.g., GMSA-DVOPS$).


⚙️ 4. Automating Certificate Import

You can use PowerShell to automate:

  • Loading configuration from a JSON file (certConfig.json)
  • Reading the .pfx and installing it for machine use
  • Granting key access to the gMSA

Example Configuration

1
2
3
4
5
{
  "certPassword": "YourSecurePassword",
  "certFileName": "signingCert.pfx",
  "signingServiceUser": "DOMAIN\\GMSA-DVOPS$"
}

Scripts:

  • Inspect-Cert.ps1: inspects EKU and metadata before installation
  • Install-CodeSignCert.ps1: installs the certificate securely

Inspect-Cert.ps1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# =============================
# Inspect certificate before installation
# =============================

# Get script directory
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition

# Load config from JSON
$configPath = Join-Path $scriptDir "certConfig.json"
if (!(Test-Path $configPath)) {
    Write-Error "Configuration file certConfig.json not found."
    exit 1
}

$config = Get-Content $configPath | ConvertFrom-Json
$pfxFile = Join-Path $scriptDir $config.certFile
$certPassword = $config.certPassword

# Validate file exists
if (!(Test-Path $pfxFile)) {
    Write-Error "PFX file '$pfxFile' not found."
    exit 1
}

# Load certificate from PFX without importing
try {
    $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
    $cert.Import($pfxFile, $certPassword, "Exportable,PersistKeySet")

    Write-Host "=== Certificate Information ==="
    Write-Host "Subject         : $($cert.Subject)"
    Write-Host "Issuer          : $($cert.Issuer)"
    Write-Host "Thumbprint      : $($cert.Thumbprint)"
    Write-Host "Valid From      : $($cert.NotBefore)"
    Write-Host "Valid Until     : $($cert.NotAfter)"
    Write-Host "Has Private Key : $($cert.HasPrivateKey)"

    $eku = $cert.Extensions | Where-Object { $_.Oid.FriendlyName -eq "Enhanced Key Usage" }
    if ($eku) {
        $decoded = New-Object System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension $eku, $true
        Write-Host "Enhanced Key Usages:"
        foreach ($usage in $decoded.EnhancedKeyUsages) {
            Write-Host " - $($usage.FriendlyName) ($($usage.Value))"
        }
    } else {
        Write-Host "No Enhanced Key Usage extensions found."
    }

} catch {
    Write-Error "Failed to read the certificate: $_"
}

Install-CodeSignCert.ps1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# =============================
# Install certificate for code signing from PFX
# =============================

# Get script directory
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition

# Load config from JSON
$configPath = Join-Path $scriptDir "certConfig.json"
if (!(Test-Path $configPath)) {
    Write-Error "Configuration file certConfig.json not found."
    exit 1
}

$config = Get-Content $configPath | ConvertFrom-Json
$pfxFile = Join-Path $scriptDir $config.certFile
$certPassword = ConvertTo-SecureString -String $config.certPassword -AsPlainText -Force
$gmsaAccount = $config.signingServiceUser

# Validate file exists
if (!(Test-Path $pfxFile)) {
    Write-Error "PFX file '$pfxFile' not found."
    exit 1
}

# Import certificate into LocalMachine\My store
try {
    $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
    $cert.Import($pfxFile, $config.certPassword, "Exportable,PersistKeySet")

    $store = New-Object System.Security.Cryptography.X509Certificates.X509Store("My", "LocalMachine")
    $store.Open("ReadWrite")
    $store.Add($cert)
    $store.Close()

    Write-Host "Certificate successfully installed in LocalMachine\My store."

    # Set private key permissions for gMSA account
    $thumbprint = $cert.Thumbprint
    $keyPath = "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys"
    $keyFile = Get-ChildItem $keyPath | Where-Object {
        (Get-Acl $_.FullName).Owner -like "*$($thumbprint.Substring(0,8))*"
    } | Select-Object -First 1

    if ($keyFile) {
        $acl = Get-Acl $keyFile.FullName
        $permission = "$gmsaAccount","Read","Allow"
        $accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule $permission
        $acl.AddAccessRule($accessRule)
        Set-Acl -Path $keyFile.FullName -AclObject $acl
        Write-Host "Permissions granted to $gmsaAccount on key file: $($keyFile.FullName)"
    } else {
        Write-Warning "Could not locate the private key file for the certificate."
    }

} catch {
    Write-Error "Failed to install certificate: $_"
}


🔍 5. EKU — Enhanced Key Usage: Required?

Scenario: No EKU Present

Some certificates may not list any Enhanced Key Usage. According to RFC 5280, a missing EKU means the certificate is valid for all usages.

But in practice:

Many tools expect an EKU for Code Signing:

  • OID: 1.3.6.1.5.5.7.3.3 (Code Signing)
  • Friendly name: Code Signing

Without EKU:

  • Tools may show warnings
  • SmartScreen may block the file
  • Timestamping may fail
  • EV policies not satisfied

✅ Recommendation

Use certificates with explicit EKU for code signing.

If your certificate lacks EKU, request a reissue from your CA with the correct settings.


🧪 6. How to Inspect a Certificate Before Installing

1
2
3
$pfx = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
$pfx.Import("signingCert.pfx", "yourpassword", "Exportable,PersistKeySet")
$pfx.Extensions | Where-Object { $_.Oid.FriendlyName -eq "Enhanced Key Usage" } | Format-List

Also helpful:

1
2
3
4
$pfx.Issuer
$pfx.Subject
$pfx.NotBefore
$pfx.NotAfter

🧾 Summary

Topic Best Practice
Certificate location LocalMachine\My
Who needs access? gMSA account running the DevOps Agent
Sign what? Your own EXE and DLL files
Skip signing Third-party NuGet libraries
EKU required? ✅ Recommended (OID: 1.3.6.1.5.5.7.3.3)
Timestamping ✅ Always use, e.g., Digicert /tr URL
Tool signtool with SHA256 and RFC3161 timestamping
Key location %ProgramData%\Microsoft\Crypto\RSA\MachineKeys