TLSleuth.psm1

#Region '.\private\Build-CertificateChain.ps1' -1

function Build-CertificateChain {
<#
.SYNOPSIS
    Builds a trust chain for a certificate with optional revocation checking.
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
        [switch]$CheckRevocation
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Revocation=$([bool]$CheckRevocation), Thumbprint=$($Certificate.Thumbprint))"
    try {
        $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new()
        $chain.ChainPolicy.RevocationMode = if ($CheckRevocation) {
            [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Online
        } else {
            [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck
        }
        $chain.ChainPolicy.RevocationFlag    = [System.Security.Cryptography.X509Certificates.X509RevocationFlag]::EndCertificateOnly
        $chain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::NoFlag

        $isTrusted = $chain.Build($Certificate)
        Write-Verbose "[$fn] Chain Status = $($chain.ChainStatus)"
        $status = if ($chain.ChainStatus) { ,@($chain.ChainStatus) } else { ,@() }
        $subjects = if ($chain.ChainElements) {
            ,@($chain.ChainElements | ForEach-Object { $_.Certificate.Subject })
        } else { ,@() }

        Write-Verbose "[$fn] Chain built. IsTrusted=$isTrusted; StatusCount=$($status.Count)"
        [PSCustomObject]@{
            Chain         = $chain
            IsTrusted     = $isTrusted
            ChainStatus   = $status
            ChainSubjects = $subjects
        }
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Build-CertificateChain.ps1' 45
#Region '.\private\Connect-TcpWithTimeout.ps1' -1

function Connect-TcpWithTimeout {
<#
.SYNOPSIS
    Opens a TcpClient and connects with a timeout.
 
.OUTPUTS
    PSCustomObject { TcpClient, NetworkStream }
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Hostname,
        [Parameter(Mandatory)][int]$Port,
        [int]$TimeoutMs = 10000
    )
    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    $TimeoutMs = [Math]::Max(1000, $TimeoutMs)
    Write-Verbose "[$fn] Begin (Target=$Hostname :$Port, TimeoutMs=$TimeoutMs)"
    $tcp = $null
    try {
        $tcp = [System.Net.Sockets.TcpClient]::new()
        $tcp.NoDelay = $true

        $task = $tcp.ConnectAsync($Hostname, $Port)
        if (-not $task.Wait($TimeoutMs)) {
            throw [System.TimeoutException]::new("Connection timeout after ${TimeoutMs}ms to $Hostname :$Port")
        }
        $netStream = $tcp.GetStream()
        Write-Verbose "[$fn] Connected to $Hostname :$Port"
        [PSCustomObject]@{ TcpClient = $tcp; NetworkStream = $netStream }
    } catch {
        try { if ($tcp) { $tcp.Dispose() } } catch {}
        throw
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Connect-TcpWithTimeout.ps1' 39
#Region '.\private\Format-ChainStatusStrings.ps1' -1

function Format-ChainStatusStrings {
<#
.SYNOPSIS
    Converts X509ChainStatus[] into human-readable strings.
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Security.Cryptography.X509Certificates.X509ChainStatus[]]$ChainStatus
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Count=$($ChainStatus?.Count))"
    try {
        if (-not $ChainStatus -or $ChainStatus.Count -eq 0) { return ,@() }
        ,@(
            foreach ($s in $ChainStatus) {
                "{0}: {1}" -f $s.Status, ($s.StatusInformation.Trim())
            }
        )
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Format-ChainStatusStrings.ps1' 27
#Region '.\private\Get-AIAUrls.ps1' -1

function Get-AIAUrls {
<#
.SYNOPSIS
    Extracts Authority Information Access (AIA) URLs from a certificate.
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Thumbprint=$($Cert.Thumbprint))"
    try {
        $oid = '1.3.6.1.5.5.7.1.1'
        $ext = $Cert.Extensions | Where-Object { $_.Oid.Value -eq $oid } | Select-Object -First 1
        if (-not $ext) { return ,@() }
        try {
            $data = [System.Security.Cryptography.AsnEncodedData]::new($ext.Oid, $ext.RawData)
            $txt  = $data.Format($true)
            $uris =
                ($txt -split '(,|\r?\n)') |
                Where-Object { $_ -match '(http|ldap)s?://' } |
                ForEach-Object { $_.Trim() } |
                Select-Object -Unique
            if ($uris) { return ,@($uris) } else { return ,@() }
        } catch {
            return ,@()
        }
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Get-AIAUrls.ps1' 36
#Region '.\private\Get-CDPUrls.ps1' -1

function Get-CDPUrls {
<#
.SYNOPSIS
    Extracts CRL Distribution Point (CDP) URLs from a certificate.
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Thumbprint=$($Cert.Thumbprint))"
    try {
        $oid = '2.5.29.31'
        $ext = $Cert.Extensions | Where-Object { $_.Oid.Value -eq $oid } | Select-Object -First 1
        if (-not $ext) { return ,@() }
        try {
            $data = [System.Security.Cryptography.AsnEncodedData]::new($ext.Oid, $ext.RawData)
            $txt  = $data.Format($true)
            $uris =
                ($txt -split '(,|\r?\n)') |
                Where-Object { $_ -match '(http|ldap)s?://' } |
                ForEach-Object { $_.Trim() } |
                Select-Object -Unique
            if ($uris) { return ,@($uris) } else { return ,@() }
        } catch {
            return ,@()
        }
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Get-CDPUrls.ps1' 36
#Region '.\private\Get-CertificateSAN.ps1' -1

function Get-CertificateSAN {
<#
.SYNOPSIS
    Returns DNS Subject Alternative Names from a certificate as an array (possibly empty).
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Thumbprint=$($Cert.Thumbprint))"
    try {
        # Prefer modern property when available
        $dnsProp = [System.Security.Cryptography.X509Certificates.X509Certificate2].GetProperty('DnsNameList')
        if ($dnsProp) {
            try {
                $val = $dnsProp.GetValue($Cert, $null)
                if ($val) {
                    $names = @($val) | ForEach-Object { $_.ToString().Trim() } |
                             Where-Object { $_ } | Select-Object -Unique
                    return ,@($names)
                }
            } catch { }
        }

        # Fallback: parse SAN extension text
        $sanOid = '2.5.29.17'
        $ext = $Cert.Extensions | Where-Object { $_.Oid.Value -eq $sanOid } | Select-Object -First 1
        if (-not $ext) { return ,@() }

        try {
            $data = New-Object System.Security.Cryptography.AsnEncodedData($ext.Oid, $ext.RawData)
            $txt  = $data.Format($true)
            $names =
                ($txt -split '(,|\r?\n)') |
                ForEach-Object {
                    $line = $_.Trim()
                    if ($line -match 'DNS Name\=(.+)$') { $Matches[1].Trim(); return }
                    if ($line -match 'DNS:(.+)$')       { $Matches[1].Trim(); return }
                } |
                Where-Object { $_ } |
                Sort-Object -Unique   # <- ensures deterministic order
            if ($names) { return ,@($names) } else { return ,@() }
        } catch {
            return ,@()
        }
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Get-CertificateSAN.ps1' 55
#Region '.\private\Get-HandshakeInfo.ps1' -1

function Get-HandshakeInfo {
<#
.SYNOPSIS
    Extracts negotiated TLS protocol and cipher details from an SslStream.
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Net.Security.SslStream]$SslStream
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin"
    try {
        $supports = Test-NegotiatedCipherSuiteSupport

        $negProtocol = $SslStream.SslProtocol
        $cipherName = $null
        $cipherStrength = $null
        $hashAlgorithm = $null
        $kexAlgorithm = $null
        $kexStrength  = $null

        if ($supports -and $SslStream.NegotiatedCipherSuite) {
            try { $cipherName = $SslStream.NegotiatedCipherSuite.ToString() } catch {}
            try { $cipherStrength = $SslStream.CipherStrength } catch {}
            try { $hashAlgorithm  = $SslStream.HashAlgorithm.ToString() } catch {}
            try { $kexAlgorithm   = $SslStream.KeyExchangeAlgorithm.ToString() } catch {}
            try { $kexStrength    = $SslStream.KeyExchangeStrength } catch {}
        } else {
            try { $cipherName = $SslStream.CipherAlgorithm.ToString() } catch {}
            try { $cipherStrength = $SslStream.CipherStrength } catch {}
            try { $hashAlgorithm  = $SslStream.HashAlgorithm.ToString() } catch {}
            try { $kexAlgorithm   = $SslStream.KeyExchangeAlgorithm.ToString() } catch {}
            try { $kexStrength    = $SslStream.KeyExchangeStrength } catch {}
        }

        [PSCustomObject]@{
            Protocol            = $negProtocol.ToString()
            CipherSuite         = $cipherName
            CipherStrengthBits  = $cipherStrength
            HashAlgorithm       = $hashAlgorithm
            KeyExchangeAlgorithm= $kexAlgorithm
            KeyExchangeStrength = $kexStrength
        }
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Get-HandshakeInfo.ps1' 52
#Region '.\private\Get-SslProtocolsEnum.ps1' -1

function Get-SslProtocolsEnum {
<#
.SYNOPSIS
    Maps string names to [System.Security.Authentication.SslProtocols] flags.
 
.DESCRIPTION
    Accepts names like 'SystemDefault','Tls12','Tls13' and returns the enum.
    'SystemDefault' maps to ::None to let .NET choose defaults.
 
.PARAMETER Names
    Array of protocol names.
 
.OUTPUTS
    System.Security.Authentication.SslProtocols
#>

[CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$Names
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Names=$($Names -join ','))"
    try {
        $enumType = [System.Security.Authentication.SslProtocols]
        if ($Names -contains 'SystemDefault' -and $Names.Count -eq 1) {
            return [System.Security.Authentication.SslProtocols]::None
        }

        $value = [System.Security.Authentication.SslProtocols]::None
        foreach ($n in $Names) {
            if ($n -eq 'SystemDefault') { continue }
            $value = $value -bor [System.Enum]::Parse($enumType, $n)
        }
        if ($value -eq [System.Security.Authentication.SslProtocols]::None) {
            return [System.Security.Authentication.SslProtocols]::None
        }
        return $value
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Get-SslProtocolsEnum.ps1' 45
#Region '.\private\New-TLSleuthCertificateReport.ps1' -1

function New-TLSleuthCertificateReport {
<#
.SYNOPSIS
    Creates the final TLSleuth certificate report object.
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Hostname,
        [Parameter(Mandatory)][int]$Port,
        [string]$ConnectedIp,
        [Parameter(Mandatory)][string]$SNI,
        [Parameter(Mandatory)][psobject]$Handshake,
        [Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
        [psobject]$ChainInfo,
        [System.Security.Cryptography.X509Certificates.X509Chain]$CapturedChain,
        [string[]]$ValidationErrors = @(),
        [string[]]$SANs = @(),
        [string[]]$AIA  = @(),
        [string[]]$CDP  = @()
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Host=$Hostname :$Port, Protocol=$($Handshake.Protocol))"
    try {
        $now = [DateTimeOffset]::UtcNow
        $pubKeyAlgo = $Certificate.PublicKey.Oid.FriendlyName
        $keySize    = $Certificate.PublicKey.Key.KeySize
        $sigAlgo    = $Certificate.SignatureAlgorithm.FriendlyName

        $isTrusted     = $null
        $chainSubjects = ,@()
        $chainStatus   = ,@()

        if ($ChainInfo) {
            $isTrusted     = $ChainInfo.IsTrusted
            $chainSubjects = $ChainInfo.ChainSubjects
            $chainStatus   = $ChainInfo.ChainStatus
        } elseif ($CapturedChain) {
            $isTrusted     = ($CapturedChain.ChainStatus.Count -eq 0)
            $chainSubjects = if ($CapturedChain.ChainElements) {
                ,@($CapturedChain.ChainElements | ForEach-Object { $_.Certificate.Subject })
            } else { ,@() }
            $chainStatus   = if ($CapturedChain.ChainStatus) { ,@($CapturedChain.ChainStatus) } else { ,@() }
        }

        $chainStatusStrings = if ($chainStatus) { Format-ChainStatusStrings -ChainStatus $chainStatus } else { ,@() }
        $keyExchange = if ($Handshake.KeyExchangeStrength) {
            "{0} ({1}-bit)" -f $Handshake.KeyExchangeAlgorithm, $Handshake.KeyExchangeStrength
        } else { $Handshake.KeyExchangeAlgorithm }

        Write-Verbose "[$fn] Hello from Report"
        [PSCustomObject]@{
            PSTypeName         = 'TLSleuth.CertificateReport'
            Host               = $Hostname
            Port               = $Port
            ConnectedIp        = $ConnectedIp
            SNI                = $SNI
            Protocol           = $Handshake.Protocol
            CipherSuite        = $Handshake.CipherSuite
            CipherStrengthBits = $Handshake.CipherStrengthBits
            HashAlgorithm      = $Handshake.HashAlgorithm
            KeyExchange        = $keyExchange
            Certificate        = [PSCustomObject]@{
                Subject            = $Certificate.Subject
                CommonName         = ($Certificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false))
                Issuer             = $Certificate.Issuer
                SerialNumber       = $Certificate.SerialNumber
                Thumbprint         = $Certificate.Thumbprint
                NotBefore          = $Certificate.NotBefore
                NotAfter           = $Certificate.NotAfter
                DaysUntilExpiry    = [int]([Math]::Floor(($Certificate.NotAfter.ToUniversalTime() - $now.UtcDateTime).TotalDays))
                SignatureAlgorithm = $sigAlgo
                PublicKeyAlgorithm = $pubKeyAlgo
                KeySize            = $keySize
                SANs               = @($SANs)
                AIA                = @($AIA)
                CRLDistribution    = @($CDP)
                IsSelfSigned       = Test-IsSelfSigned -Cert $Certificate
            }
            IsTrusted          = $isTrusted
            ChainSubjects      = $chainSubjects
            ChainStatus        = $chainStatusStrings
            ValidationErrors   = @($ValidationErrors | ForEach-Object { $_ })
            RawCertificate     = $Certificate
        }
    } catch {
        Write-Verbose "[$fn] Report failed: $($_.Exception.Message)"
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\New-TLSleuthCertificateReport.ps1' 94
#Region '.\private\Resolve-Endpoint.ps1' -1

function Resolve-Endpoint {
<#
.SYNOPSIS
    Best-effort DNS resolution of a host to an IP address.
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Hostname
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Hostname=$Hostname)"
    try {
        try {
            $ips = [System.Net.Dns]::GetHostAddresses($Hostname)
            $ip = if ($ips -and $ips.Length -gt 0) { $ips[0] } else { $null }
            if ($ip) { Write-Verbose "[$fn] Resolved $Hostname -> $ip" }
            return $ip
        } catch {
            Write-Verbose "[$fn] Resolution failed: $($_.Exception.Message)"
            return $null
        }
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Resolve-Endpoint.ps1' 30
#Region '.\private\Start-TlsHandshake.ps1' -1

function Start-TlsHandshake {
<#
.SYNOPSIS
    Performs an SNI-aware TLS handshake over an existing NetworkStream.
 
.OUTPUTS
    PSCustomObject { SslStream, RemoteCertificate, CapturedChain, ValidationErrors }
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.IO.Stream]$NetworkStream,
        [Parameter(Mandatory)][string]$TargetHost,
        [Parameter(Mandatory)][System.Security.Authentication.SslProtocols]$Protocols,
        [switch]$CheckRevocation,
        [int]$TimeoutMs = 10000
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    $TimeoutMs = [Math]::Max(1000, $TimeoutMs)
    Write-Verbose "[$fn] Begin (SNI=$TargetHost, Protocols=$Protocols, TimeoutMs=$TimeoutMs, Revocation=$([bool]$CheckRevocation))"
    $sslStream = $null
    try {
        $certErrors = New-Object System.Collections.Generic.List[string]
        $capturedChain = $null

        $validationCallback = {
            param(
                [object]$sender,
                [System.Security.Cryptography.X509Certificates.X509Certificate]$certificate,
                [System.Security.Cryptography.X509Certificates.X509Chain]$chain,
                [System.Net.Security.SslPolicyErrors]$sslPolicyErrors
            )
            try {
                if ($chain -and $certificate) {
                    $tmp = [System.Security.Cryptography.X509Certificates.X509Chain]::new()
                    $tmp.ChainPolicy.RevocationMode   = $chain.ChainPolicy.RevocationMode
                    $tmp.ChainPolicy.RevocationFlag   = $chain.ChainPolicy.RevocationFlag
                    $tmp.ChainPolicy.VerificationFlags= $chain.ChainPolicy.VerificationFlags
                    $tmp.ChainPolicy.VerificationTime = $chain.ChainPolicy.VerificationTime
                    [void]$tmp.Build([System.Security.Cryptography.X509Certificates.X509Certificate2]$certificate)
                    $script:capturedChain = $tmp
                }
            } catch {}
            if ($sslPolicyErrors -ne [System.Net.Security.SslPolicyErrors]::None) {
                [void]$certErrors.Add($sslPolicyErrors.ToString())
            }
            return $true
        }

        $sslStream = [System.Net.Security.SslStream]::new($NetworkStream, $false, $validationCallback)

        try {
            $opts = [System.Net.Security.SslClientAuthenticationOptions]::new()
            $opts.TargetHost = $TargetHost
            $opts.EnabledSslProtocols = $Protocols
            $opts.CertificateRevocationCheckMode = if ($CheckRevocation) {
                [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Online
            } else {
                [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck
            }
            $cts = [System.Threading.CancellationTokenSource]::new()
            try {
                $task = $sslStream.AuthenticateAsClientAsync($opts, $cts.Token)
                if (-not $task.Wait($TimeoutMs)) {
                    throw [System.TimeoutException]::new("TLS handshake timeout after ${TimeoutMs}ms")
                }
            } finally { try { $cts.Dispose() } catch {} }
        } catch {
            # Legacy path
            $sslStream.ReadTimeout  = $TimeoutMs
            $sslStream.WriteTimeout = $TimeoutMs
            $clientCerts = [System.Security.Cryptography.X509Certificates.X509CertificateCollection]::new()
            $rev = [bool]$CheckRevocation
            $sslStream.AuthenticateAsClient($TargetHost, $clientCerts, $Protocols, $rev)
        }

        $remoteCert = $sslStream.RemoteCertificate
        if (-not $remoteCert) { throw "No certificate was presented by the server." }
        if ($remoteCert -isnot [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
            $remoteCert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($remoteCert)
        }

        Write-Verbose "[$fn] Handshake complete. Protocol=$($sslStream.SslProtocol); CertCN=$($remoteCert.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::DnsName, $false))"
        [PSCustomObject]@{
            SslStream         = $sslStream
            RemoteCertificate = $remoteCert
            CapturedChain     = $capturedChain
            ValidationErrors  = ,@($certErrors)
        }
    } catch {
        try { if ($sslStream) { $sslStream.Dispose() } } catch {}
        throw
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Start-TlsHandshake.ps1' 99
#Region '.\private\Test-IsSelfSigned.ps1' -1

function Test-IsSelfSigned {
<#
.SYNOPSIS
    Returns $true if the certificate is self-signed (subject == issuer).
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert
    )
    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Thumbprint=$($Cert.Thumbprint))"
    try {
        return $Cert.Subject -eq $Cert.Issuer
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Test-IsSelfSigned.ps1' 21
#Region '.\private\Test-NegotiatedCipherSuiteSupport.ps1' -1

function Test-NegotiatedCipherSuiteSupport {
<#
.SYNOPSIS
    Detects whether .NET exposes SslStream.NegotiatedCipherSuite property.
#>

    [CmdletBinding()]
    param()

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin"
    try {
        try {
            $prop = [System.Net.Security.SslStream].GetProperty('NegotiatedCipherSuite')
            return [bool]$prop
        } catch {
            return $false
        }
    } finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Test-NegotiatedCipherSuiteSupport.ps1' 24
#Region '.\public\Get-TLSCertificate.ps1' -1

function Get-TLSCertificate {
<#
.SYNOPSIS
    Retrieves the SSL/TLS certificate from a remote server and reports handshake details.
 
.DESCRIPTION
    Orchestrates smaller private helpers to connect, handshake, collect details,
    optionally build a validation chain, and assemble a TLSleuth report.
 
.PARAMETER Hostname
    DNS name or IP of the server.
 
.PARAMETER Port
    TCP port to connect to. Defaults to 443.
 
.PARAMETER ServerName
    SNI server name (TargetHost). Defaults to -Host.
 
.PARAMETER TlsProtocols
    TLS protocol(s) to allow. Defaults to SystemDefault.
 
.PARAMETER TimeoutSec
    Connection + handshake timeout in seconds. Default: 10.
 
.PARAMETER IncludeChain
    If specified, builds chain/trust info locally.
 
.PARAMETER CheckRevocation
    If specified, attempts revocation checking (OCSP/CRL).
 
.PARAMETER RawCertificate
    If specified, outputs the raw X509Certificate2.
 
.OUTPUTS
    TLSleuth.CertificateReport or X509Certificate2
#>

[CmdletBinding()]
    [OutputType([pscustomobject], [System.Security.Cryptography.X509Certificates.X509Certificate2])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('DnsName','ComputerName','Target','Name','CN')]
        [string]$Hostname,

        [int]$Port = 443,
        [string]$ServerName,

        [ValidateSet('SystemDefault','Ssl3','Tls','Tls11','Tls12','Tls13')]
        [string[]]$TlsProtocols = @('SystemDefault'),

        [int]$TimeoutSec = 10

    )

    begin {
        $fn = $MyInvocation.MyCommand.Name
        $pipelineSw = [System.Diagnostics.Stopwatch]::StartNew()
        Write-Verbose "[$fn] Begin (TimeoutSec=$TimeoutSec, Protocols=$($TlsProtocols -join ','))"

        $sslProtocolsEnum = Get-SslProtocolsEnum -Names $TlsProtocols
        $processed = 0
    }
    process {
        $processed++
        Write-Verbose "[$fn] Processing Host=$Hostname"
        $targetHost = if ($ServerName) { $ServerName } else { $Hostname }
        if ([string]::IsNullOrWhiteSpace($targetHost)) {
            throw "ServerName/Host is empty."
        }

        $resolvedIp = Resolve-Endpoint -Host $Hostname
        Write-Verbose "[$fn] Resolved $Hostname=$resolvedIp"
        $timeoutMs  = [Math]::Max(1000, $TimeoutSec * 1000)

        $tcp = $null; $net = $null; $sslInfo = $null
        try {
            $conn = Connect-TcpWithTimeout -Host $resolvedIp -Port $Port -TimeoutMs $timeoutMs
            $tcp  = $conn.TcpClient
            $net  = $conn.NetworkStream

            $sslInfo = Start-TlsHandshake -NetworkStream $net -TargetHost $targetHost -Protocols $sslProtocolsEnum -CheckRevocation:$CheckRevocation -TimeoutMs $timeoutMs

            $remoteCert = $sslInfo.RemoteCertificate
            return $remoteCert


        } catch {
            $err = $_
            $msg = if ($err.Exception) { $err.Exception.Message } else { $err.ToString() }
            $ex  = [System.Exception]::new("Get-TLSleuthCertificate failed for $Hostname :$Port - $msg", $err.Exception)
            $record = New-Object System.Management.Automation.ErrorRecord(
                $ex, 'TLSleuth.GetTLSleuthCertificateFailed',
                [System.Management.Automation.ErrorCategory]::InvalidOperation, $Hostname
            )
            Write-Error $record
        } finally {
            try { if ($sslInfo -and $sslInfo.SslStream) { $sslInfo.SslStream.Dispose() } } catch {}
            try { if ($net) { $net.Dispose() } } catch {}
            try { if ($tcp) { $tcp.Close(); $tcp.Dispose() } } catch {}
        }
    }
    end {
        $pipelineSw.Stop()
        Write-Verbose "[$($MyInvocation.MyCommand.Name)] Complete (Processed=$processed) in $($pipelineSw.Elapsed)"
    }
}
#EndRegion '.\public\Get-TLSCertificate.ps1' 106
#Region '.\public\Get-TLSleuthCertificate.ps1' -1

function Get-TLSleuthCertificate {
<#
.SYNOPSIS
    Retrieves the SSL/TLS certificate from a remote server and reports handshake details.
 
.DESCRIPTION
    Orchestrates smaller private helpers to connect, handshake, collect details,
    optionally build a validation chain, and assemble a TLSleuth report.
 
.PARAMETER Hostname
    DNS name or IP of the server.
 
.PARAMETER Port
    TCP port to connect to. Defaults to 443.
 
.PARAMETER ServerName
    SNI server name (TargetHost). Defaults to -Host.
 
.PARAMETER TlsProtocols
    TLS protocol(s) to allow. Defaults to SystemDefault.
 
.PARAMETER TimeoutSec
    Connection + handshake timeout in seconds. Default: 10.
 
.PARAMETER IncludeChain
    If specified, builds chain/trust info locally.
 
.PARAMETER CheckRevocation
    If specified, attempts revocation checking (OCSP/CRL).
 
.PARAMETER RawCertificate
    If specified, outputs the raw X509Certificate2.
 
.OUTPUTS
    TLSleuth.CertificateReport or X509Certificate2
#>

[CmdletBinding()]
    [OutputType([pscustomobject], [System.Security.Cryptography.X509Certificates.X509Certificate2])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('DnsName','ComputerName','Target','Name','CN')]
        [string]$Hostname,

        [int]$Port = 443,
        [string]$ServerName,

        [ValidateSet('SystemDefault','Ssl3','Tls','Tls11','Tls12','Tls13')]
        [string[]]$TlsProtocols = @('SystemDefault'),

        [int]$TimeoutSec = 10,

        [switch]$IncludeChain,
        [switch]$CheckRevocation,
        [switch]$RawCertificate
    )

    begin {
        $fn = $MyInvocation.MyCommand.Name
        $pipelineSw = [System.Diagnostics.Stopwatch]::StartNew()
        Write-Verbose "[$fn] Begin (TimeoutSec=$TimeoutSec, Protocols=$($TlsProtocols -join ','))"

        $sslProtocolsEnum = Get-SslProtocolsEnum -Names $TlsProtocols
        $processed = 0
    }
    process {
        $processed++
        Write-Verbose "[$fn] Processing Host=$Hostname"
        $targetHost = if ($ServerName) { $ServerName } else { $Hostname }
        if ([string]::IsNullOrWhiteSpace($targetHost)) {
            throw "ServerName/Host is empty."
        }

        $resolvedIp = Resolve-Endpoint -Host $Hostname
        Write-Verbose "[$fn] Resolved $Hostname=$resolvedIp"
        $timeoutMs  = [Math]::Max(1000, $TimeoutSec * 1000)

        $tcp = $null; $net = $null; $sslInfo = $null
        try {
            $conn = Connect-TcpWithTimeout -Host $resolvedIp -Port $Port -TimeoutMs $timeoutMs
            $tcp  = $conn.TcpClient
            $net  = $conn.NetworkStream

            $sslInfo = Start-TlsHandshake -NetworkStream $net -TargetHost $targetHost -Protocols $sslProtocolsEnum -CheckRevocation:$CheckRevocation -TimeoutMs $timeoutMs

            $remoteCert = $sslInfo.RemoteCertificate
            if ($RawCertificate) { $remoteCert; return }

            $hs   = Get-HandshakeInfo -SslStream $sslInfo.SslStream
            $aia  = Get-AIAUrls    -Cert $remoteCert
            $cdp  = Get-CDPUrls    -Cert $remoteCert
            $sans = Get-CertificateSAN -Cert $remoteCert

            $chainInfo = $null
            if ($IncludeChain) {
                $chainInfo = Build-CertificateChain -Certificate $remoteCert -CheckRevocation:$CheckRevocation
            }

            $report = New-TLSleuthCertificateReport `
                -Host $Hostname -Port $Port -ConnectedIp $resolvedIp.ToString() -SNI $targetHost `
                -Handshake $hs -Certificate $remoteCert -ChainInfo $chainInfo `
                -CapturedChain $sslInfo.CapturedChain -ValidationErrors $sslInfo.ValidationErrors `
                -SANs $sans -AIA $aia -CDP $cdp

            $report

        } catch {
            $err = $_
            $msg = if ($err.Exception) { $err.Exception.Message } else { $err.ToString() }
            $ex  = [System.Exception]::new("Get-TLSleuthCertificate failed for $Hostname :$Port - $msg", $err.Exception)
            $record = New-Object System.Management.Automation.ErrorRecord(
                $ex, 'TLSleuth.GetTLSleuthCertificateFailed',
                [System.Management.Automation.ErrorCategory]::InvalidOperation, $Hostname
            )
            Write-Error $record
        } finally {
            try { if ($sslInfo -and $sslInfo.SslStream) { $sslInfo.SslStream.Dispose() } } catch {}
            try { if ($net) { $net.Dispose() } } catch {}
            try { if ($tcp) { $tcp.Close(); $tcp.Dispose() } } catch {}
        }
    }
    end {
        $pipelineSw.Stop()
        Write-Verbose "[$($MyInvocation.MyCommand.Name)] Complete (Processed=$processed) in $($pipelineSw.Elapsed)"
    }
}
#EndRegion '.\public\Get-TLSleuthCertificate.ps1' 126