Automating certificate rollover for Azure AD applications using Azure Functions and KeyVault

There are several documentation pages on docs.microsoft.com on managing application registration certificate rollover, including several github repos from Microsoft, all of which are either using a silly approach with high privileged user account (Global Admin, Application Admin, etc) or does not delve into details on how to solve the issue with least privilege and in a proper automated fashion when using KeyVault.

In this post I will try to document a few of the things I have experienced while implementing this using the addKey Graph endpoint.

The addKey Graph endpoint is an endpoint available for app registrations, enabling them to roll over their certificate / key, without any permissions required other than being able to use a client credential grant with certificates. There are no permissions required, as shown in the documentation:

This means that if you possess the clientid and a registred certificate on the app, you can add new certificates. I am not going to re-document the whole endpoint, but I will provide you with example payloads and working example code.

Example payloads, extending upon the samples from Microsoft.

First of all, let’s see how this works. The API endpoint we use is https://graph.microsoft.com/v1.0/applications/{id}/addKey, with “id” being the objectid of the application registration (not the clientid). This endpoint requires a bearer token, which should be gotten using client credentials with JWT signed by a certificate. Code shown in the Azure Function code provided.

In order to add a new certificate, just like uploading a .cer file in the Azure Portal, we can use a payload like the Microsoft documentation:

{
    "keyCredential": {
        "type": "AsymmetricX509Cert",
        "usage": "Verify",
        "key": "MIIDYDCCAki..."
    },
    "passwordCredential": null,
    "proof":"eyJ0eXAiOiJ..."
}

The key value is essentially just your raw dump of a .cer file, or the content of a certificate downloaded from Azure KeyVault using the following button:

Or by using the cer attribute when requesting a certificate from the certificates KeyVault API endpoint.

The proof on the other hand is more interesting. The proof is essentially a JWT, signed with one of the existing certificates of the application. This can be the same certificate used to get an access token initially, but it is an additional security measure to make sure you have access to the private key of at least one of the certificates, not just “picked up an access token” somewhere, such as through a man-in-the-middle attack. Since this is a JWT, it is essentially a header, a payload and a signature, base 64’ed with dots between them, so we can split them into the following:

Header

{
    "alg":"RS256",
    "kid":"<CERTIFICATETHUMBPRINT>",
    "x5t":"<CERTIFICATE X5T>",
    "typ":"JWT"
}

PowerShell sample for creating header. The input certificate parameter is the certificate object from KeyVault, having “cer” and “x5t” properties.

<#
.Synopsis
    Creates a base64 string of a default JWT header, with certificate information
.DESCRIPTION
    Creates a base64 string of a default JWT header, with certificate information
.EXAMPLE
    Get-JWTHeader -Certificate $cert
#>
function Get-JWTHeader {
    [CmdletBinding()]

    param (
        [Parameter(Mandatory=$true)] $Certificate
    ) 

    Process {
        $certificate2 = [System.Security.Cryptography.X509Certificates.X509Certificate2]([System.Convert]::FromBase64String($certificate.cer))

        [System.Convert]::ToBase64String(([System.Text.Encoding]::UTF8.GetBytes((
            [ordered] @{
                "alg" = "RS256"
                "kid" = $certificate2.Thumbprint
                "x5t" = $certificate.x5t
                "typ" = "JWT"
            } | ConvertTo-Json -Compress
        )))) -replace "=+$" # Required to remove padding
    }
}

Payload

{
    "nbf":1628714763,
    "aud":"00000002-0000-0000-c000-000000000000",
    "iss":"<APPLICATION OBJECTID (NOT CLIENT ID)>",
    "exp":1628715363,
    "iat":1628714776
}

Then you create a, RS256 signature of the SHA256 of BASE64(Header).BASE64(Payload) and append it as SE64(Payload).BASE64(signature). Perfectly normal. However, let me save you a buttload of hurt right now:

The Proof does not support padding characters! You need to replace any trailing = signs in order for the proof to work… super anoying, since the Azure AD token endpoint does not care about this characters. Hope this saves someone the 5-6 hours of my life that I lost on this issue…

Example PowerShell code that takes an InputString (Header.Payload), a KeyVault KID url and uses the KeyVaultHeaders dictionary for contacting the KeyVault API:

<#
.Synopsis
    Creates a base64 string of a default JWT header, with certificate information
.DESCRIPTION
    Creates a base64 string of a default JWT header, with certificate information
.EXAMPLE
    Get-AppendedSignature -InputString "base64header.base64payload" -Kid "https://kv.vault.azure.net/keys/abc/xxx" -KeyVaultHeaders @{...}
#>
function Get-AppendedSignature {
    [CmdletBinding()]

    param (
        [Parameter(Mandatory=$true)] [String] $InputString,

        [Parameter(Mandatory=$true)] $Kid, 

        [Parameter(Mandatory=$true)] [System.Collections.Hashtable] $KeyVaultHeaders
    )

    Process {
        # Hash it with SHA-256:
        $hasher = [System.Security.Cryptography.HashAlgorithm]::Create('sha256')
        $hash = $hasher.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($InputString))
        
        # Use KeyVault to sign our hash using RS256 (matching jwt header)
        $vaultKeyUri = "$($Kid)/sign?api-version=7.0"
        $signature = Invoke-RestMethod -Method POST -Uri $vaultKeyUri -ContentType 'application/json' -Headers $KeyVaultHeaders -Body (@{
            alg = "RS256"
            value = [Convert]::ToBase64String($hash)
        } | ConvertTo-Json)
        
        # Create full JWT with the signature we got from KeyVault (just append .SIGNATURE)
        return $InputString + "." + $signature.value
    }
}

So, using all of this we can actually very simply build a solution that looks like this:

In this solution, the Function App is responsible for rolling over a Key Vault certificate, updating the certificate on an App Registration (without requiring any permissions other than “sign” access to Key Vault). This way, any system that is provided access to the Key Vault, can consume the certificate and authenticate as the Azure AD application.

Let us start by creating an Azure Function app in Azure:

Keep it essentially free to run, by using serverless:

Wait a little bit for Azure to deploy your function app, and go into the resource. Find “Identity” in the left menu, and enable System Assigned Identity:

Continue to create a Key Vault:

During the creation wizard, add the rolloverapp identity with Key (Sign) and Certificate (Get, Update, Create) permissions.

After creating the Key Vault, create a certificate:

After the certificate is created, download the CER for it. We will provision this as a “first time thing” on our app registration.

Now, go to Azure AD, and we will create the app registration for which to roll over the certificate. This can of course be an existing app.

Note down the app id, object id and tenant id of the app registration. You will need all of them for our next steps.

Go to certificate and secrets and upload the certificate.

Ok, so to summarize what we have now. We have an empty function app, where we have granted the managed service identity access to a KeyVault, that has a certificate “demo”, which is uploaded to an app registration. At this point we are able to use client credential grant and roll over the certificate, we just need to configure the function app and upload our code.

Go to the Azure Function App again, and find “Configuration” in the left menu. Use the “New application setting” button as below, to create the settings “TENANTID”, “CLIENTID”, “OBJECTID”, “KeyVaultName”, “CERTIFICATENAME”:

Now we have all the required information for our function, let’s upload our code. We can add this as a timer trigger:

param($Timer)



<#
.Synopsis
    Creates a signed JWT of the Payload
.DESCRIPTION
    Creates a signed JWT of the Payload
.EXAMPLE
    Get-SignedJWT -Payload @{sub="abc"} -Certificate $cert -KeyVaultHeaders @{...}
#>
function Get-SignedJWT {
    [CmdletBinding()]

    param (
        [Parameter(Mandatory=$true)] [System.Collections.Hashtable] $Payload,

        [Parameter(Mandatory=$true)] $Certificate, 

        [Parameter(Mandatory=$true)] [System.Collections.Hashtable] $KeyVaultHeaders, 

        [Parameter(Mandatory=$false)] [Boolean] $DoNotAddJtiClaim = $false
    )

    Process {
        # Build our JWT header
        $JWTHeader = Get-JWTHeader -Certificate $certificate

        # Set EXP to unixtime
        if(!$Payload.ContainsKey("exp")) {
            $Payload["exp"] = [int] (Get-Date(Get-Date).AddHours(1).ToUniversalTime()-uformat "%s") # Unixtime + 3600
        } elseif($Payload["exp"].GetType().Name -eq "DateTime") {
            $Payload["exp"] = [int] (Get-Date($Payload["exp"]).ToUniversalTime()-uformat "%s") # Unixtime
        } else {
            $Payload["exp"] = [int] (Get-Date(Get-Date).AddHours(1).ToUniversalTime()-uformat "%s") # Unixtime + 3600
        }

        # Set EXP to unixtime
        if(!$Payload.ContainsKey("nbf")) {
            $Payload["nbf"] = [int] (Get-Date(Get-Date).ToUniversalTime()-uformat "%s") # Unixtime
        } elseif($Payload["nbf"].GetType().Name -eq "DateTime") {
            $Payload["nbf"] = [int] (Get-Date($Payload["nbf"]).ToUniversalTime()-uformat "%s") # Unixtime
        } else {
            $Payload["nbf"] = [int] (Get-Date(Get-Date).ToUniversalTime()-uformat "%s") # Unixtime
        }

        # Add jti if missing
        if(!$Payload.ContainsKey("jti") -and !$DoNotAddJtiClaim) {
            $Payload["jti"] = [guid]::NewGuid().ToString()
        }

        # Add iat
        $Payload["iat"] = [int] (Get-Date(Get-Date).ToUniversalTime()-uformat "%s") # Unixtime
        
        # Build our JWT Payload
        $JWTPayload = $Payload | ConvertTo-Json -Depth 5 -Compress
        
        # Create JWT without signature (base64 of header DOT base64 of payload)
        function ConvertTo-Base64($String) {[System.Convert]::ToBase64String(([System.Text.Encoding]::UTF8.GetBytes($String)))}
        $JWTWithoutSignature = $JWTHeader + "." + ((ConvertTo-Base64 $JWTPayload) -replace "=+$")
        
        Get-AppendedSignature -InputString $JWTWithoutSignature -Kid $certificate.kid -KeyVaultHeaders $KeyVaultHeaders
    }
}

<#
.Synopsis
    Creates a base64 string of a default JWT header, with certificate information
.DESCRIPTION
    Creates a base64 string of a default JWT header, with certificate information
.EXAMPLE
    Get-JWTHeader -Certificate $cert
#>
function Get-JWTHeader {
    [CmdletBinding()]

    param (
        [Parameter(Mandatory=$true)] $Certificate
    ) 

    Process {
        $certificate2 = [System.Security.Cryptography.X509Certificates.X509Certificate2]([System.Convert]::FromBase64String($certificate.cer))

        [System.Convert]::ToBase64String(([System.Text.Encoding]::UTF8.GetBytes((
            [ordered] @{
                "alg" = "RS256"
                "kid" = $certificate2.Thumbprint
                "x5t" = $certificate.x5t
                "typ" = "JWT"
            } | ConvertTo-Json -Compress
        )))) -replace "=+$" # Required to remove padding
    }
}

<#
.Synopsis
    Creates a base64 string of a default JWT header, with certificate information
.DESCRIPTION
    Creates a base64 string of a default JWT header, with certificate information
.EXAMPLE
    Get-AppendedSignature -InputString "base64header.base64payload" -Kid "https://kv.vault.azure.net/keys/abc/xxx" -KeyVaultHeaders @{...}
#>
function Get-AppendedSignature {
    [CmdletBinding()]

    param (
        [Parameter(Mandatory=$true)] [String] $InputString,

        [Parameter(Mandatory=$true)] $Kid, 

        [Parameter(Mandatory=$true)] [System.Collections.Hashtable] $KeyVaultHeaders
    )

    Process {
        # Hash it with SHA-256:
        $hasher = [System.Security.Cryptography.HashAlgorithm]::Create('sha256')
        $hash = $hasher.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($InputString))
        
        # Use KeyVault to sign our hash using RS256 (matching jwt header)
        $vaultKeyUri = "$($Kid)/sign?api-version=7.0"
        $signature = Invoke-RestMethod -Method POST -Uri $vaultKeyUri -ContentType 'application/json' -Headers $KeyVaultHeaders -Body (@{
            alg = "RS256"
            value = [Convert]::ToBase64String($hash)
        } | ConvertTo-Json)
        
        # Create full JWT with the signature we got from KeyVault (just append .SIGNATURE)
        return $InputString + "." + $signature.value
    }
}

<#
.Synopsis
    Get tenantid of domains
.DESCRIPTION
    Get tenantid of domains
.EXAMPLE
    Get-TenantId -Domain "abc.com"
#>
function Get-TenantId {
    [CmdletBinding()]

    param (
        [Parameter(Mandatory=$true)] [String] $Domain
    )

    Process {
        [System.Reflection.Assembly]::LoadWithPartialName("System.Xml.Linq") | Out-Null
        [xml] $XDocument = [System.Xml.Linq.XDocument]::Load( ("https://sts.windows.net/{0}/FederationMetadata/2007-06/FederationMetadata.xml" -f $Domain))
        $XDocument.EntityDescriptor.entityID -split "/" | Where-Object {$_ -match "^[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}$"}
    }
}



# Set module path and load helpers module
$VerbosePreference = "Continue"

# Get access token to KeyVault and Microsoft Graph using MSI
if($env:MSI_ENDPOINT) {
    if($env:UAICLIENTID) {
        $authenticationResult = Invoke-RestMethod -Method Get -Headers @{Secret = $env:MSI_SECRET} -Uri ($env:MSI_ENDPOINT +'?resource=https://vault.azure.net&api-version=2017-09-01&clientid=' +$env:UAICLIENTID)
    } else {
        $authenticationResult = Invoke-RestMethod -Method Get -Headers @{Secret = $env:MSI_SECRET} -Uri ($env:MSI_ENDPOINT +'?resource=https://vault.azure.net&api-version=2017-09-01')
    }
    $keyVaultHeaders = @{Authorization = "Bearer $($authenticationResult.access_token)"}
} else {
    throw "No MSI configured"

    <#
    $t = az account get-access-token --resource "https://vault.azure.net" | ConvertFrom-Json
    $keyVaultHeaders = @{Authorization = "Bearer $($t.accessToken)"}
    #>
}

$clientid = $ENV:CLIENTID
$objectid = $ENV:OBJECTID
$tenantid = $ENV:TENANTID
$certificatename = $ENV:CERTIFICATENAME

# Get certificate
Write-Verbose "Getting certificate $certificatename" -Verbose
try {
    $vaultSecretUri = "https://$($ENV:KeyVaultName).vault.azure.net/certificates/$($certificatename)?api-version=7.0"
    $currentCertificate = Invoke-RestMethod -Method GET -Uri $vaultSecretUri -Headers $keyVaultHeaders
} catch {
    throw "Unable to get certificate $($certificatename): $($_)"
}


# Create graph headers using certificate signed JWT
Write-Verbose "Getting access token using certificate based authentication"
try {
    $JWT = Get-SignedJWT @{
        "aud" = "https://login.microsoftonline.com/$($tenantid)/oauth2/token"
        "iss" = $clientid
        "sub" = $clientid
    } -Certificate $currentCertificate -KeyVaultHeaders $keyVaultHeaders
    $token = Invoke-RestMethod "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -ContentType "application/x-www-form-urlencoded" -Body "client_id=$clientid&scope=https://graph.microsoft.com/.default&grant_type=client_credentials&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=$JWT" -Method POST
    $graphHeaders = @{Authorization = "Bearer $($token.access_token)"}
} catch {
    throw "Unable to get access token for clientid '$clientid', tenantid '$tenantid': $($_)"
}


# Create new certificate version
Write-Verbose "Creating new certificate version for certificate '$certificatename'"
try {
    $vaultCertUri = "https://$($ENV:KeyVaultName).vault.azure.net/certificates/$($certificatename)/create?api-version=7.2"
    $body = @{
        policy = @{
            key_props = @{
                exportable = $false
                kty = "RSA"
                key_size = 4096
                reuse_key = $false
            }
            secret_props = @{
                contentType = "application/x-pkcs12"
            }
            x509_props = @{
                subject = "CN=breakglass.tietoevry.com"
                validity_months = 1
            }
            issuer = @{
                name = "Self"
            }
        }
    } | ConvertTo-Json -Depth 6
    $certificateCreationResult = Invoke-RestMethod -Method POST -Uri $vaultCertUri -Headers $keyVaultHeaders -Body $body -ContentType "application/json"
} catch {
    throw "Unable to create new certificate version: $($_)"
}

# Wait for certificate to become active
Write-Verbose "Getting new certificate version" -Verbose
try {
    $inc = 0
    do {
        $inc += 1
        if($inc -gt 100) {
            throw "Unable to get pending certificate"
        }
        $pendingcertificate = Invoke-RestMethod -Uri "https://$($ENV:KeyVaultName).vault.azure.net/certificates/$($certificatename)/pending?api-version=7.2" -Headers $keyVaultHeaders
        if($pendingcertificate.status -ne "completed") {
            start-sleep -Seconds 1
        }
    } while($pendingcertificate.status -ne "completed")
    $newCertificate = Invoke-RestMethod -Uri "https://$($ENV:KeyVaultName).vault.azure.net/certificates/$($certificatename)?api-version=7.2" -Headers $keyVaultHeaders
} catch {
    throw "Unable to get new certificate version: $($_)"
}

# Get existing application, in order to get keyCredentials
Write-Verbose "Getting application from Graph"
try {
    $application = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/applications/$($objectid)" -Headers $graphHeaders
} catch {
    throw "Unable to get application with objectid '$objectid' from Graph"
}


# Upload new certificate to app registration
Write-Verbose "Uploading certificate to application" -Verbose
try {
    $addkeybody = @{
        keyCredential = @{
            type = "AsymmetricX509Cert"
            usage = "Verify"        
            key = $newCertificate.cer
        }
        passwordCredential = $null
        proof = Get-SignedJWT -Certificate $currentCertificate -KeyVaultHeaders $KeyVaultHeaders -DoNotAddJtiClaim:$true -Payload @{
            "aud" = "00000002-0000-0000-c000-000000000000"
            "iss" = $objectid
            "nbf" = Get-Date
            "exp" = (Get-Date).AddMinutes(10)
        }
    }

    $newKey = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/applications/$($objectid)/addKey" -Body ($addkeybody | ConvertTo-Json -Depth 5) -ContentType "application/json" -Method POST -Headers $graphHeaders

    if($newKey.keyId) {
        Write-Verbose "Successfully added new key with keyId $($newKey.keyId)" -Verbose
    } else {
        Write-Error "Unable to create new key"
    }
} catch {
    throw "Unable to upload new certificate to app: $($_)"
}


do {
    # Create graph headers using certificate signed JWT
    try {
        $JWT = Get-SignedJWT @{
            "aud" = "https://login.microsoftonline.com/$($tenantid)/oauth2/token"
            "iss" = $clientid
            "sub" = $clientid
        } -Certificate $newCertificate -KeyVaultHeaders $keyVaultHeaders
        $token = Invoke-RestMethod "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -ContentType "application/x-www-form-urlencoded" -Body "client_id=$clientid&scope=https://graph.microsoft.com/.default&grant_type=client_credentials&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=$JWT" -Method POST
        $graphHeaders = @{Authorization = "Bearer $($token.access_token)"}
    } catch {
        Write-Verbose "Waiting for new certificate to become valid" -Verbose
    }

    if(!$token.access_token) {
        Start-Sleep 1
    }
} while(!$token.access_token)


# Remove existing key credentials
$application.keyCredentials | ForEach-Object {
    Write-Verbose "Removing key $($_.keyId)" -Verbose

    $removekeybody = @{
        keyId = $_.keyId
        proof = Get-SignedJWT -Payload @{
            aud = "00000002-0000-0000-c000-000000000000"
            iss = $objectid
            nbf = Get-Date
            exp = (Get-Date).AddMinutes(10)
        } -KeyVaultHeaders $keyVaultHeaders -DoNotAddJtiClaim:$true -Certificate $newCertificate
    }
    Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/applications/$($objectid)/removeKey" -Body ($removekeybody | ConvertTo-Json -Depth 5) -ContentType "application/json" -Method POST -Headers $graphHeaders
}

After saving, we can manually trigger the function:

We should now see a new certificate version in KeyVault:

And we should see the same certificate uploaded to our app registration:

Cool? Now, by default Azure Function timer trigger is triggered every 5 minutes. This is way too often, so let’s change it to weekly:

Hope this helps someone, either by using this solution, or by figuring out how to actually use the addKeys and removeKeys endpoint to do a proper certificate rollover!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s