Public/Invoke-ESTmTLSRequest.ps1

# Define this callback in C#, so it doesn't require a PowerShell runspace to run. This way, it can be called back in a different thread.
$csCodeSelectFirstCertificateCallback = @'
public static class CertificateCallbacks
{
    public static System.Security.Cryptography.X509Certificates.X509Certificate SelectFirstCertificate(
        object sender,
        string targetHost,
        System.Security.Cryptography.X509Certificates.X509CertificateCollection localCertificates,
        System.Security.Cryptography.X509Certificates.X509Certificate remoteCertificate,
        string[] acceptableIssuers)
    {
        return localCertificates[0];
    }
 
    public static System.Net.Security.LocalCertificateSelectionCallback SelectionCallback {
        get {
            return SelectFirstCertificate;
        }
    }
}
'@

Add-Type -TypeDefinition $csCodeSelectFirstCertificateCallback -Language CSharp


<#
.SYNOPSIS
    Sends a mTLS request to an EST endpoint.
 
.DESCRIPTION
    Sends a mTLS request to an EST endpoint. This function uses the HttpClientHandler in PowerShell 5 and the SocketsHttpHandler in PowerShell 7.
    The function will throw an error if the certificate does not have a private key.
 
.PARAMETER Url
    The URL of the EST service.
 
.PARAMETER Endpoint
    The endpoint of the EST service. Default is '/.well-known/est/simplereenroll'.
 
.PARAMETER Certificate
    The certificate to use for the mTLS request.
 
.PARAMETER Request
    The request to send to the EST service.
 
.EXAMPLE
    $Certificate = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.Subject -eq "CN=ESTClient" }
    $PrivateKey = New-PrivateKeyFromCertificate -Certificate $Certificate
    $Request = New-CSRFromCertificate -Certificate $Certificate
    $Response = Invoke-ESTmTLSRequest -Url "https://est.example.com" -Certificate $Certificate -Request $Request
#>


Function Invoke-ESTmTLSRequest {
    [CmdletBinding()]
    [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2Collection])]
    Param(
        [Parameter(Mandatory)]
        [Alias('AppServiceUrl')]
        [String]$Url,
        [Parameter()]
        [String]$Endpoint = '/.well-known/est/simplereenroll',
        [Parameter(Mandatory)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
        [Parameter(Mandatory)]
        [String]$Request
    )

    If (-not $Certificate.HasPrivateKey) {
        throw "$($MyInvocation.MyCommand): Certificate does not have a private key"
    }

    if ($PSVersionTable.PSVersion.Major -lt 7) {
        Write-Verbose "$($MyInvocation.MyCommand): Detected PowerShell 5: Using HttpClientHandler"
        Add-Type -AssemblyName System.Net.Http
        $handler = New-Object System.Net.Http.HttpClientHandler
        $handler.ClientCertificates.Add($Certificate) | Out-Null
    } else {
        Write-Verbose "$($MyInvocation.MyCommand): Detected PowerShell 7: Using SocketsHttpHandler"
        $handler = New-Object System.Net.Http.SocketsHttpHandler

        # SocketsHttpHandler's ClientCertificateOptions is internal. So we need to use reflection to set it. If we leave it at 'Automatic', it would require the certificate to be in the store.
        try {
            $SocketHandlerType = $handler.GetType()
            $ClientCertificateOptionsProperty = $SocketHandlerType.GetProperty("ClientCertificateOptions", [System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::NonPublic)
            $ClientCertificateOptionsProperty.SetValue($handler, [System.Net.Http.ClientCertificateOption]::Manual)
        }
        catch {
            Write-Warning "$($MyInvocation.MyCommand): Couldn't set ClientCertificateOptions to Manual. This should cause an issue if the certificate is not in the MY store. This is probably due to a too recent .NET version (> 8.0)."
        }
        $handler.SslOptions.LocalCertificateSelectionCallback = [CertificateCallbacks]::SelectionCallback # This just selects the first certificate in the collection. We only provide a single certificate, so this suffices.
        $handler.SslOptions.ClientCertificates = [System.Security.Cryptography.X509Certificates.X509Certificate2Collection]::new()
        $null = $handler.SslOptions.ClientCertificates.Add($Certificate)
    }

    $Uri = ($Url -replace '/$') + $Endpoint

    $requestmessage = [System.Net.Http.HttpRequestMessage]::new()
    $requestmessage.Content = [System.Net.Http.StringContent]::new(
        $Request,
        [System.Text.Encoding]::UTF8,"application/pkcs10"
    )
    $requestmessage.Content.Headers.ContentType = "application/pkcs10"
    $requestmessage.Method = 'POST'
    $requestmessage.RequestUri = $Uri

    $client = New-Object System.Net.Http.HttpClient($handler)
    Write-Verbose "$($MyInvocation.MyCommand): Sending EST request to $Uri"

    try {
        $httpResponseMessage = $client.SendAsync($requestmessage).GetAwaiter().GetResult()
    }
    catch {
        # dump details of the exception, including InnerException
        $ex = $_.Exception
        Write-Error "$($MyInvocation.MyCommand): $($ex.GetType()): $($ex.Message)"
        while ($ex.InnerException) {
            $ex = $ex.InnerException
            Write-Error "$($MyInvocation.MyCommand): $($ex.GetType()): $($ex.Message)"
        }
    }
    If ($httpResponseMessage.StatusCode -eq 'InternalServerError') {
        throw "$($MyInvocation.MyCommand): Failed to renew certificate. Status code: $($httpResponseMessage.StatusCode) - Check if certificate renewals are allowed on this endpoint and check application logs"
    } ElseIf ($httpResponseMessage.StatusCode -ne [System.Net.HttpStatusCode]::OK) {
        throw "$($MyInvocation.MyCommand): Failed to renew certificate. Status code: $($httpResponseMessage.StatusCode)"
    }

    $Response =  $httpResponseMessage.Content.ReadAsStringAsync().Result
    $client.Dispose()
    $handler.Dispose()

    $CertificateCollection = [System.Security.Cryptography.X509Certificates.X509Certificate2Collection]::new()
    $DERCertificate = [System.Convert]::FromBase64String($Response)
    $CertificateCollection.Import($DERCertificate)

    Return $CertificateCollection
}