Files
Invoke-HTTPBootBiosConfigur…/Toolkit/Functions/Get-EndpointCertificateChain.ps1
gsadmin 746bddcf62 feat: Endpoint certificate chain retrieval and hardware-verified profile constraints
- Add Get-EndpointCertificateChain toolkit function: direct synchronous TLS handshake (TcpClient/SslStream) against the boot endpoint, captures the presented chain within the X509 validation callback (untrusted/expired/self-signed endpoints still harvest), converts each certificate to PEM (64 character wrapping), exports the full chain to a PEM file, and selects the last self-signed certificate as the root. Verified within both Windows PowerShell 5.1 and PowerShell 7 (isolated closure scope works around the 5.1 GetNewClosure validated-parameter clone issue)
- Main script: when RootCertificateURL is not explicitly specified and the boot URL is https, the root certificate is retrieved directly from the boot endpoint with graceful fallback to downloading RootCertificateURL. Explicitly specifying the parameter skips the endpoint retrieval
- Warn (but still export/embed) when a certificate is not RSA - Dell BIOS certificate import requires RSA and rejects ECDSA with "not RSA format"
- Hardware-verified profile constraints (CCTK 5.2.2, live --Set/--Get/--Delete cycles): the cert field is capped at 2047 characters so only a single root certificate can be embedded (chain bundles are rejected with exit 150), and IntegrityInfo with a NON-EMPTY digest is mandatory (empty digest or missing element is rejected with exit 157)
- Restore boot image digest computation by default (required by the BIOS) and add -BootImageDigest parameter to place a precomputed SHA-256 value without downloading the boot image
- Warn when the certificate content exceeds the 2047 character BIOS field limit
- Docs: hardware-verified field constraints section, updated Digest/cert element notes, exit code 157 and the 150 field-validation variant, boot image digest lifecycle guidance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:08:47 -04:00

383 lines
25 KiB
PowerShell

#region Get-EndpointCertificateChain
Function Get-EndpointCertificateChain
{
<#
.SYNOPSIS
Retrieves the TLS certificate chain presented by an HTTPS endpoint and exports it to PEM format.
.DESCRIPTION
A synchronous TLS handshake is performed directly against the endpoint (TcpClient and SslStream) and the certificate chain is captured within the certificate validation callback. Validation always succeeds for the purposes of the capture, so untrusted, expired, or self signed endpoints can still be harvested. Each certificate within the chain is converted to PEM format by using the X509 classes, and the full chain can optionally be written to a PEM file.
The connection is deliberately made directly (never through a proxy), because the BIOS HTTP(s) boot feature contacts the endpoint directly as well - the probe therefore represents exactly what the firmware will experience. The handshake is performed synchronously so that the capture callback executes on the calling thread, which is required for it to work within both Windows PowerShell 5.1 and PowerShell 7.
The chain is returned leaf first. The root certificate is the last self signed certificate within the chain; when the chain does not resolve to a self signed certificate (for example when the issuing root is not present within the local certificate store), the topmost available certificate is returned as the root with a warning.
Dell BIOS HTTP boot profile certificate import requires RSA certificates. Certificates that do not use an RSA public key (for example ECDSA) are logged with a warning, but they are still converted and exported.
.PARAMETER URL
The HTTPS URL of the endpoint to retrieve the certificate chain from. Only the scheme, host, and port are relevant - the path is not downloaded.
.PARAMETER Timeout
The maximum duration to wait for the endpoint connection and handshake. Defaults to 15 seconds.
.PARAMETER ExportPath
An optional file path that the full certificate chain is written to in PEM format (UTF-8 without a byte order mark).
.PARAMETER ContinueOnError
Return a result object with the Succeeded property set to false instead of throwing a terminating error when the chain cannot be retrieved.
.EXAMPLE
$GetEndpointCertificateChainResult = Get-EndpointCertificateChain -URL 'https://prod.ipxe.example.com/2PXE/boot/x64/snponly_x64.efi' -ExportPath 'C:\Windows\Temp\Chain.pem' -ContinueOnError
Switch ($GetEndpointCertificateChainResult.Succeeded)
{
{($_ -eq $True)}
{
Write-Output -InputObject ($GetEndpointCertificateChainResult.RootCertificate.PEMContent)
}
}
.NOTES
The returned object contains: Succeeded, URL, ChainList (one entry per certificate: Index, Subject, Issuer, Thumbprint, NotBefore, NotAfter, KeyAlgorithm, IsSelfSigned, PEMContent, Certificate), LeafCertificate, RootCertificate, FullChainPEM, and ExportPath.
.LINK
https://learn.microsoft.com/dotnet/api/system.security.cryptography.x509certificates.x509certificate2
#>
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[Alias('U')]
[System.URI]$URL,
[Parameter(Mandatory=$False)]
[ValidateNotNullOrEmpty()]
[Alias('T')]
[System.TimeSpan]$Timeout,
[Parameter(Mandatory=$False)]
[ValidateNotNullOrEmpty()]
[Alias('EP')]
[System.IO.FileInfo]$ExportPath,
[Parameter(Mandatory=$False)]
[Alias('COE')]
[Switch]$ContinueOnError
)
Try
{
$ErrorActionPreference = 'Stop'
[String]$CmdletName = $MyInvocation.MyCommand.Name
$WriteLogMessage.Invoke(0, @("Function `'$($CmdletName)`' is beginning. Please Wait..."))
#region Set default parameter values
Switch ($True)
{
{($Null -ieq $Timeout)}
{
[System.TimeSpan]$Timeout = [System.TimeSpan]::FromSeconds(15)
}
}
#endregion
$OutputObjectProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$OutputObjectProperties.Succeeded = $False
$OutputObjectProperties.URL = $URL
$OutputObjectProperties.ChainList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
$OutputObjectProperties.LeafCertificate = $Null
$OutputObjectProperties.RootCertificate = $Null
$OutputObjectProperties.FullChainPEM = [System.String]::Empty
$OutputObjectProperties.ExportPath = $Null
Switch ($URL.Scheme -ieq 'https')
{
{($_ -eq $False)}
{
Throw "The URL scheme must be https in order to retrieve a TLS certificate chain. [URL: $($URL.AbsoluteUri)]"
}
}
#region Define the PEM conversion scriptblock
[ScriptBlock]$ConvertToPEM = {
Param
(
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate
)
[String]$CertificateBase64 = [System.Convert]::ToBase64String($Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
$PEMBuilder = New-Object -TypeName 'System.Text.StringBuilder'
$Null = $PEMBuilder.Append('-----BEGIN CERTIFICATE-----')
$Null = $PEMBuilder.Append("`n")
For ($CertificateBase64Index = 0; $CertificateBase64Index -lt $CertificateBase64.Length; $CertificateBase64Index = $CertificateBase64Index + 64)
{
$Null = $PEMBuilder.Append($CertificateBase64.Substring($CertificateBase64Index, [System.Math]::Min(64, $CertificateBase64.Length - $CertificateBase64Index)))
$Null = $PEMBuilder.Append("`n")
}
$Null = $PEMBuilder.Append('-----END CERTIFICATE-----')
Write-Output -InputObject ($PEMBuilder.ToString())
}
#endregion
#region Perform the TLS handshake and capture the certificate chain
$ChainCaptureList = New-Object -TypeName 'System.Collections.Generic.List[System.Security.Cryptography.X509Certificates.X509Certificate2]'
#The callback always returns true so that untrusted, expired, or self signed endpoints can still be harvested. The certificates are cloned from their raw data so that they remain usable after the handshake completes. The closure is created within an isolated child scope so that only the capture list is captured, because within Windows PowerShell 5.1, GetNewClosure() clones every local variable including unbound validated parameters, which otherwise fails validation.
[System.Net.Security.RemoteCertificateValidationCallback]$CertificateCaptureCallback = & {
Param
(
$CaptureList
)
#The closure is emitted as a bare expression, because Write-Output treats a script block argument as a delay-bind block and refuses it without pipeline input.
({
Param
(
$SenderObject,
$Certificate,
$Chain,
$SslPolicyErrors
)
Try
{
Switch (($Null -ine $Chain) -and ($Chain.ChainElements.Count -gt 0))
{
{($_ -eq $True)}
{
ForEach ($ChainElement In $Chain.ChainElements)
{
$CaptureList.Add((New-Object -TypeName 'System.Security.Cryptography.X509Certificates.X509Certificate2' -ArgumentList @(, $ChainElement.Certificate.RawData)))
}
}
Default
{
Switch ($Null -ine $Certificate)
{
{($_ -eq $True)}
{
$CaptureList.Add((New-Object -TypeName 'System.Security.Cryptography.X509Certificates.X509Certificate2' -ArgumentList @(, $Certificate.GetRawCertData())))
}
}
}
}
}
Catch
{
}
Write-Output -InputObject ($True)
}.GetNewClosure())
} $ChainCaptureList
$WriteLogMessage.Invoke(0, @("Attempting to retrieve the TLS certificate chain from `"$($URL.Host):$($URL.Port)`". Please Wait..."))
$TcpClient = New-Object -TypeName 'System.Net.Sockets.TcpClient'
$TcpClient.ReceiveTimeout = $Timeout.TotalMilliseconds
$TcpClient.SendTimeout = $Timeout.TotalMilliseconds
$SslStream = $Null
Try
{
$TcpClientConnectTask = $TcpClient.ConnectAsync($URL.Host, $URL.Port)
Switch ($TcpClientConnectTask.Wait($Timeout.TotalMilliseconds))
{
{($_ -eq $False)}
{
Throw "A TCP connection to `"$($URL.Host):$($URL.Port)`" could not be established within $($Timeout.TotalSeconds) second(s)."
}
}
#The handshake is performed synchronously so that the capture callback executes on the calling thread, which is required within both Windows PowerShell 5.1 and PowerShell 7.
$SslStream = New-Object -TypeName 'System.Net.Security.SslStream' -ArgumentList @($TcpClient.GetStream(), $False, $CertificateCaptureCallback)
$SslStream.ReadTimeout = $Timeout.TotalMilliseconds
$SslStream.WriteTimeout = $Timeout.TotalMilliseconds
$Null = $SslStream.AuthenticateAsClient($URL.Host)
}
Finally
{
Switch ($Null -ine $SslStream)
{
{($_ -eq $True)}
{
$Null = $SslStream.Dispose()
}
}
$Null = $TcpClient.Dispose()
}
Switch ($ChainCaptureList.Count -gt 0)
{
{($_ -eq $False)}
{
Throw "A TLS certificate chain could not be retrieved from `"$($URL.Scheme)://$($URL.Authority)`". The endpoint may be unreachable."
}
}
#endregion
#region Convert the captured certificate chain to PEM format
$KeyAlgorithmOIDTable = New-Object -TypeName 'System.Collections.Generic.Dictionary[[String], [String]]'
$KeyAlgorithmOIDTable.'1.2.840.113549.1.1.1' = 'RSA'
$KeyAlgorithmOIDTable.'1.2.840.10045.2.1' = 'ECDSA'
$KeyAlgorithmOIDTable.'1.2.840.10040.4.1' = 'DSA'
$FullChainPEMBuilder = New-Object -TypeName 'System.Text.StringBuilder'
For ($ChainCaptureListIndex = 0; $ChainCaptureListIndex -lt $ChainCaptureList.Count; $ChainCaptureListIndex++)
{
$ChainCertificate = $ChainCaptureList[$ChainCaptureListIndex]
[String]$ChainCertificateKeyAlgorithm = "$($ChainCertificate.PublicKey.Oid.Value)"
Switch ($KeyAlgorithmOIDTable.ContainsKey($ChainCertificateKeyAlgorithm))
{
{($_ -eq $True)}
{
[String]$ChainCertificateKeyAlgorithm = $KeyAlgorithmOIDTable[$ChainCertificateKeyAlgorithm]
}
}
$ChainEntryProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$ChainEntryProperties.Index = $ChainCaptureListIndex
$ChainEntryProperties.Subject = "$($ChainCertificate.Subject)"
$ChainEntryProperties.Issuer = "$($ChainCertificate.Issuer)"
$ChainEntryProperties.Thumbprint = "$($ChainCertificate.Thumbprint)"
$ChainEntryProperties.NotBefore = $ChainCertificate.NotBefore
$ChainEntryProperties.NotAfter = $ChainCertificate.NotAfter
$ChainEntryProperties.KeyAlgorithm = $ChainCertificateKeyAlgorithm
$ChainEntryProperties.IsSelfSigned = ($ChainCertificate.Subject -ieq $ChainCertificate.Issuer)
$ChainEntryProperties.PEMContent = $ConvertToPEM.InvokeReturnAsIs($ChainCertificate)
$ChainEntryProperties.Certificate = $ChainCertificate
$ChainEntry = New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ChainEntryProperties)
$OutputObjectProperties.ChainList.Add($ChainEntry)
$Null = $FullChainPEMBuilder.Append($ChainEntry.PEMContent)
$Null = $FullChainPEMBuilder.Append("`n")
$WriteLogMessage.Invoke(0, @("Chain certificate $($ChainCaptureListIndex + 1) of $($ChainCaptureList.Count): [Subject: $($ChainEntry.Subject)] [Issuer: $($ChainEntry.Issuer)] [Key Algorithm: $($ChainEntry.KeyAlgorithm)] [Self Signed: $($ChainEntry.IsSelfSigned)] [Expiration: $($ChainEntry.NotAfter.ToString('o'))] [Thumbprint: $($ChainEntry.Thumbprint)]"))
Switch ($True)
{
{($ChainEntry.KeyAlgorithm -ine 'RSA')}
{
$WriteLogMessage.Invoke(2, @("The certificate `"$($ChainEntry.Subject)`" uses the `"$($ChainEntry.KeyAlgorithm)`" key algorithm.", "Dell BIOS HTTP boot profile certificate import requires RSA certificates, so the BIOS may reject this certificate with a `"not RSA format`" error. The certificate will still be exported."))
}
{($ChainEntry.NotAfter -lt (Get-Date))}
{
$WriteLogMessage.Invoke(2, @("The certificate `"$($ChainEntry.Subject)`" has expired. [Expiration: $($ChainEntry.NotAfter.ToString('o'))]"))
}
}
}
$OutputObjectProperties.FullChainPEM = $FullChainPEMBuilder.ToString()
$OutputObjectProperties.LeafCertificate = $OutputObjectProperties.ChainList[0]
#endregion
#region Determine the root certificate (The last self signed certificate within the chain)
For ($ChainListIndex = $OutputObjectProperties.ChainList.Count - 1; $ChainListIndex -ge 0; $ChainListIndex--)
{
Switch (($Null -ieq $OutputObjectProperties.RootCertificate) -and ($OutputObjectProperties.ChainList[$ChainListIndex].IsSelfSigned -eq $True))
{
{($_ -eq $True)}
{
$OutputObjectProperties.RootCertificate = $OutputObjectProperties.ChainList[$ChainListIndex]
}
}
}
Switch ($Null -ieq $OutputObjectProperties.RootCertificate)
{
{($_ -eq $True)}
{
$OutputObjectProperties.RootCertificate = $OutputObjectProperties.ChainList[$OutputObjectProperties.ChainList.Count - 1]
$WriteLogMessage.Invoke(2, @("The retrieved certificate chain does not resolve to a self signed root certificate. The topmost available certificate will be used instead, which may stop working when the certificate authority rotates its intermediate certificates. [Subject: $($OutputObjectProperties.RootCertificate.Subject)]"))
}
}
$WriteLogMessage.Invoke(0, @("Root Certificate: [Subject: $($OutputObjectProperties.RootCertificate.Subject)] [Key Algorithm: $($OutputObjectProperties.RootCertificate.KeyAlgorithm)] [Thumbprint: $($OutputObjectProperties.RootCertificate.Thumbprint)]"))
#endregion
#region Export the full certificate chain to a PEM file (When requested)
Switch (([System.String]::IsNullOrEmpty($ExportPath) -eq $False) -and ([System.String]::IsNullOrWhiteSpace($ExportPath) -eq $False))
{
{($_ -eq $True)}
{
Switch ([System.IO.Directory]::Exists($ExportPath.Directory.FullName))
{
{($_ -eq $False)}
{
$Null = [System.IO.Directory]::CreateDirectory($ExportPath.Directory.FullName)
}
}
$WriteLogMessage.Invoke(0, @("Attempting to export the full certificate chain to `"$($ExportPath.FullName)`". Please Wait..."))
$Null = [System.IO.File]::WriteAllText("$($ExportPath.FullName)", $OutputObjectProperties.FullChainPEM, (New-Object -TypeName 'System.Text.UTF8Encoding' -ArgumentList @($False)))
$OutputObjectProperties.ExportPath = $ExportPath
}
}
#endregion
$OutputObjectProperties.Succeeded = $True
Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($OutputObjectProperties))
}
Catch
{
$ExceptionPropertyDictionary = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$ExceptionPropertyDictionary.Add('Message', $_.Exception.Message)
$ExceptionPropertyDictionary.Add('Category', $_.Exception.ErrorRecord.FullyQualifiedErrorID)
$ExceptionPropertyDictionary.Add('LineNumber', $_.InvocationInfo.ScriptLineNumber)
$ExceptionPropertyDictionary.Add('LinePosition', $_.InvocationInfo.OffsetInLine)
$ExceptionPropertyDictionary.Add('Code', $_.InvocationInfo.Line.Trim())
$ExceptionMessageList = New-Object -TypeName 'System.Collections.Generic.List[String]'
ForEach ($ExceptionProperty In $ExceptionPropertyDictionary.GetEnumerator())
{
$ExceptionMessageList.Add("[$($ExceptionProperty.Key): $($ExceptionProperty.Value)]")
}
$WriteLogMessage.Invoke(2, @("$($ExceptionMessageList -Join ' ')"))
Switch ($ContinueOnError.IsPresent)
{
{($_ -eq $True)}
{
Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($OutputObjectProperties))
}
Default
{
Throw
}
}
}
Finally
{
$WriteLogMessage.Invoke(0, @("Function `'$($CmdletName)`' is completed."))
}
}
#endregion