private/functions/Submit-AxisCsrToEST.ps1
|
function Submit-AxisCsrToEST { <# .SYNOPSIS Submits a PKCS#10 CSR to an EST (RFC 7030) server and returns the issued certificate and chain. .DESCRIPTION Issuer backend for Type='EST' certificate authorities. Implements the two mandatory EST operations (RFC 7030 https://www.rfc-editor.org/rfc/rfc7030): GET /.well-known/est[/label]/cacerts -> base64 PKCS#7 certs-only (section 4.1) -> Chain POST /.well-known/est[/label]/simpleenroll -> base64 PKCS#7 certs-only (section 4.2) -> Certificate The CSR body is the base64 DER of the PKCS#10 with Content-Type application/pkcs10 and Content-Transfer-Encoding base64 (RFC 7030 section 4.2.1). HTTP 202 means "pending" and the server adds a Retry-After header; the same request is re-sent until the certificate is issued (section 4.2.3). The private key never leaves the device: only the CSR travels. Authority keys: Type='EST' Server 'https://host:port' (scheme + host + optional port; no path) Label Optional EST label (RFC 7030 section 3.2.2), e.g. a Dogtag/EJBCA alias. Template is used when Label is missing (dispatcher convention). CredentialTarget Credential Manager target holding the HTTP Basic user/password (Get-StoredCredential, CredentialManager module). Default 'AxisPowershell-CA-<Name>'. ClientCertThumbprint Optional. Thumbprint of a client certificate in Cert:\CurrentUser\My (Windows only) for mutual TLS authentication. SkipCertificateCheck $false by default. $true disables TLS validation of the EST server (PowerShell 7: -SkipCertificateCheck; 5.1: Set-CertificateValidation -Disable). CAThumbprint Optional pin. The /cacerts response must contain a certificate with this thumbprint (mitigates the RFC 7030 section 4.1.1 bootstrap problem), otherwise the request is aborted. TimeoutSec Optional, default 300. Upper bound for pending (202) polling when -Wait is used. .PARAMETER Authority The authority hashtable from $Config.CertificateAuthorities. .PARAMETER Csr The PEM encoded certificate signing request (BEGIN [NEW] CERTIFICATE REQUEST). Raw base64 DER is accepted too. .PARAMETER Device The device the request is for. Only used for verbose/log output. .PARAMETER Wait Keep re-sending the request while the server answers 202 (pending), honouring Retry-After, up to TimeoutSec. .PARAMETER TimeoutSec Overrides $Authority.TimeoutSec. Supplying it implies -Wait. .EXAMPLE Submit-AxisCsrToEST -Authority $Config.CertificateAuthorities.Dogtag -Csr $Csr -Device 192.168.0.100 #> [cmdletbinding()] Param( [Parameter(Mandatory)] [Hashtable]$Authority, [Parameter(Mandatory)] [String]$Csr, [Parameter()] [String]$Device="", [Parameter()] [Switch]$Wait, [Parameter()] [Int]$TimeoutSec ) if(!$Authority.Server) { Throw "Authority has no Server (expected 'https://host:port')" } if($PSBoundParameters.ContainsKey('TimeoutSec')) { $Wait = $true } if(!$TimeoutSec) { $TimeoutSec = 300 if($Authority.TimeoutSec) { $TimeoutSec = [Int]$Authority.TimeoutSec } } $CAName = $Authority.Name if(!$CAName) { $CAName = $Authority.Server } #RFC 7030 section 3.2.2: https://host/.well-known/est/[label/]<operation> $BaseUri = "$($Authority.Server.TrimEnd('/'))/.well-known/est" $Label = $Authority.Label if(!$Label) { $Label = $Authority.Template } if($Label) { $BaseUri = "$BaseUri/$($Label.Trim('/'))" } #CSR -> base64 DER without PEM armour (RFC 7030 section 4.2.1: body is base64 encoded PKCS#10) if($Csr -match "-----BEGIN") { $Blocks = @(ConvertFrom-PemString -Pem $Csr) $CsrBlock = $Blocks | Where-Object { $_.Label -like "*CERTIFICATE REQUEST" } | Select-Object -First 1 if(!$CsrBlock) { Throw "Csr is not a PEM encoded certificate signing request" } $CsrBytes = $CsrBlock.Bytes } else { Try { $CsrBytes = [Convert]::FromBase64String(($Csr -replace '\s','')) } Catch { Throw "Csr is neither PEM nor base64 encoded" } } $CsrBase64 = [Convert]::ToBase64String($CsrBytes) #EST has no request id; a hash of the CSR identifies the pending request in messages/logs $Sha = [System.Security.Cryptography.SHA1]::Create() $RequestId = ([System.BitConverter]::ToString($Sha.ComputeHash($CsrBytes)) -replace '-','').Substring(0,16) $Sha.Dispose() #Common Invoke-WebRequest parameters. -UseBasicParsing exists on both editions; the Authorization header is #built by hand so that Basic auth behaves identically on 5.1 and 7 (no -Authentication/-AllowUnencryptedAuthentication). $Common = @{ UseBasicParsing = $true TimeoutSec = 60 Verbose = $false ErrorAction = 'Stop' } if($Authority.SkipCertificateCheck) { if($PSVersionTable.PSVersion.Major -ge 6) { $Common.Add('SkipCertificateCheck',$true) } else { Set-CertificateValidation -Disable } } $AuthHeaders = @{} if($Authority.ClientCertThumbprint) { if(!(Test-Path "Cert:\CurrentUser\My")) { Throw "ClientCertThumbprint requires the Windows certificate store (Cert:\CurrentUser\My) which is not available on this platform" } $ClientCert = Get-ChildItem "Cert:\CurrentUser\My" | Where-Object { $_.Thumbprint -eq $Authority.ClientCertThumbprint } | Select-Object -First 1 if(!$ClientCert) { Throw "Client certificate $($Authority.ClientCertThumbprint) not found in Cert:\CurrentUser\My" } $Common.Add('Certificate',$ClientCert) } $CredentialTarget = $Authority.CredentialTarget if(!$CredentialTarget -and $Authority.Name) { $CredentialTarget = "AxisPowershell-CA-$($Authority.Name)" } if($CredentialTarget) { $Credential = Get-StoredCredential -Target $CredentialTarget if($Credential) { $Pair = "$($Credential.UserName):$($Credential.GetNetworkCredential().Password)" $AuthHeaders["Authorization"] = "Basic $([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Pair)))" } elseif(!$ClientCert) { Throw "No credential found for target '$CredentialTarget' and no ClientCertThumbprint configured. Store one with New-StoredCredential -Target '$CredentialTarget' -Persist Enterprise" } } elseif(!$ClientCert) { Write-Verbose "EST: no CredentialTarget or ClientCertThumbprint; sending anonymous requests" } #Response content: PS 7 hands back a byte[] for non-text media types (application/pkcs7-mime); PS 5.1 a string. $GetBodyText = { Param($Response) $Content = $Response.Content if($Content -is [Byte[]]) { $Content = [System.Text.Encoding]::ASCII.GetString($Content) } if($null -eq $Content -and $Response.RawContentStream) { $Reader = New-Object System.IO.StreamReader($Response.RawContentStream) $Content = $Reader.ReadToEnd() } return [String]$Content } $ImportPkcs7 = { Param([String]$Base64) #PKCS#7 certs-only -> certificates; X509Certificate2Collection.Import(byte[]) understands PKCS#7 on both editions $Clean = $Base64 -replace '[^A-Za-z0-9+/=]','' $Collection = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection $Collection.Import([Convert]::FromBase64String($Clean)) return ,@($Collection) } #1. /cacerts (RFC 7030 section 4.1.2: GET, no authentication, base64 PKCS#7 certs-only) $CaCertsUri = "$BaseUri/cacerts" Write-Verbose "GET $CaCertsUri" Try { $Param = $Common.Clone() $Param.Add('Method','GET') $Param.Add('Uri',$CaCertsUri) $Param.Add('Headers',@{ Accept = 'application/pkcs7-mime' }) $Response = Invoke-WebRequest @Param $CaCerts = & $ImportPkcs7 (& $GetBodyText $Response) } Catch { Throw "EST /cacerts failed on $CAName ($CaCertsUri): $($_.Exception.Message)" } if($CaCerts.Count -eq 0) { Throw "EST /cacerts on $CAName returned no certificates" } if($Authority.CAThumbprint) { $Pin = ($Authority.CAThumbprint -replace '[^0-9A-Fa-f]','').ToUpper() if(!($CaCerts | Where-Object { $_.Thumbprint -eq $Pin })) { Throw "EST /cacerts on $CAName did not contain the pinned CA certificate $Pin (got: $(($CaCerts | ForEach-Object { $_.Thumbprint }) -join ', '))" } } #2. /simpleenroll (RFC 7030 section 4.2); 202 + Retry-After = pending (section 4.2.3), re-send the same request $EnrollUri = "$BaseUri/simpleenroll" $Headers = @{ 'Content-Transfer-Encoding' = 'base64' Accept = 'application/pkcs7-mime' } ForEach($Key in $AuthHeaders.Keys) { $Headers[$Key] = $AuthHeaders[$Key] } $Param = $Common.Clone() $Param.Add('Method','POST') $Param.Add('Uri',$EnrollUri) $Param.Add('Headers',$Headers) $Param.Add('ContentType','application/pkcs10') $Param.Add('Body',$CsrBase64) $Deadline = (Get-Date).AddSeconds($TimeoutSec) $Status = "Pending" $Issued = $null while($true) { Write-Verbose "POST $EnrollUri ($Device)" Try { $Response = Invoke-WebRequest @Param } Catch { $Detail = $_.Exception.Message if($_.ErrorDetails.Message) { $Detail = "$Detail $($_.ErrorDetails.Message)" } Throw "EST /simpleenroll failed on $CAName ($EnrollUri): $Detail" } $Code = [Int]$Response.StatusCode if($Code -eq 202) { #Retry-After: seconds or HTTP-date (RFC 7030 section 4.2.3 / RFC 7231 section 7.1.3) $RetryAfter = $Response.Headers['Retry-After'] if($RetryAfter -is [Array]) { $RetryAfter = $RetryAfter[0] } $Delay = 30 if($RetryAfter -match '^\d+$') { $Delay = [Int]$RetryAfter } elseif($RetryAfter) { Try { $Delay = [Int]([DateTime]::Parse($RetryAfter).ToUniversalTime() - (Get-Date).ToUniversalTime()).TotalSeconds } Catch { } } if($Delay -lt 1) { $Delay = 1 } if(!$Wait) { Throw "Certificate request $RequestId is pending approval on $CAName (retry after $Delay s)" } if((Get-Date).AddSeconds($Delay) -gt $Deadline) { Throw "Certificate request $RequestId is still pending approval on $CAName after $TimeoutSec seconds" } Write-Verbose "EST: request $RequestId pending, retrying in $Delay s" Write-Log -ID "Certificates" -Message "$Device EST request $RequestId pending on $CAName, retry in $Delay s" Start-Sleep -Seconds $Delay continue } if($Code -ne 200) { Throw "EST /simpleenroll on $CAName returned HTTP $Code" } Try { $Issued = & $ImportPkcs7 (& $GetBodyText $Response) } Catch { Throw "EST /simpleenroll on $CAName returned an unreadable PKCS#7 response: $($_.Exception.Message)" } $Status = "Issued" break } if(!$Issued -or $Issued.Count -eq 0) { Throw "EST /simpleenroll on $CAName returned no certificate" } #Leaf = a returned certificate that is not one of the CA certificates and does not issue another returned one $CaThumbs = @($CaCerts | ForEach-Object { $_.Thumbprint }) $Candidates = @($Issued | Where-Object { $CaThumbs -notcontains $_.Thumbprint }) if($Candidates.Count -eq 0) { $Candidates = @($Issued) } $Leaf = $Candidates | Where-Object { $Subject = $_.Subject; !($Issued | Where-Object { $_.Issuer -eq $Subject -and $_.Subject -ne $Subject }) } | Select-Object -First 1 if(!$Leaf) { $Leaf = $Candidates[0] } #Chain ordered leaf-nearest first by following Issuer -> Subject; leftovers appended $Pool = @($CaCerts) + @($Issued | Where-Object { $_.Thumbprint -ne $Leaf.Thumbprint }) $Chain = @() $Seen = @($Leaf.Thumbprint) $Current = $Leaf while($true) { $Next = $Pool | Where-Object { $_.Subject -eq $Current.Issuer -and $Seen -notcontains $_.Thumbprint } | Select-Object -First 1 if(!$Next) { break } $Chain += $Next $Seen += $Next.Thumbprint $Current = $Next if($Current.Subject -eq $Current.Issuer) { break } } $Chain += @($Pool | Where-Object { $Seen -notcontains $_.Thumbprint }) Write-Verbose "EST: issued serial $($Leaf.SerialNumber) for $($Leaf.Subject) on $CAName" return [pscustomobject]@{ Certificate = ConvertTo-PemString -Certificate $Leaf Chain = @($Chain | ForEach-Object { ConvertTo-PemString -Certificate $_ }) Serial = ($Leaf.SerialNumber -replace '[^0-9A-Fa-f]','').ToUpper() RequestId = $RequestId Status = $Status } } |