TLSleuth.psm1

#Region '.\private\Close-NetworkResources.ps1' -1

function Close-NetworkResources {
<#
.SYNOPSIS
    Safely disposes network resources used during TLS operations.
#>

    [CmdletBinding()]
    param(
        [System.Net.Security.SslStream]$SslStream,
        [System.IO.Stream]$NetworkStream,
        [System.Net.Sockets.TcpClient]$TcpClient
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (SslStream=$($null -ne $SslStream), NetworkStream=$($null -ne $NetworkStream), TcpClient=$($null -ne $TcpClient))"

    try {
        foreach ($resource in @($SslStream, $NetworkStream, $TcpClient)) {
            if ($null -eq $resource) { continue }

            try {
                if ($resource -is [System.IDisposable]) {
                    $resource.Dispose()
                    Write-Verbose "[$fn] Disposed $($resource.GetType().FullName)"
                }
            }
            catch {
                Write-Debug "[$fn] Dispose failed: $($_.Exception.GetType().FullName)"
            }
        }
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Close-NetworkResources.ps1' 37
#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)]
        [Alias('Host')]
        [ValidateNotNullOrEmpty()]
        [string]$Hostname,

        [Parameter(Mandatory)]
        [ValidateRange(1,65535)]
        [int]$Port,

        [ValidateRange(1000,600000)]
        [int]$TimeoutMs = 10000
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    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 {}

        $errorToThrow = $_.Exception
        if ($errorToThrow -is [System.AggregateException] -and $errorToThrow.InnerException) {
            throw $errorToThrow.InnerException
        }
        throw
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Connect-TcpWithTimeout.ps1' 56
#Region '.\private\ConvertTo-TlsCertificateResult.ps1' -1

function ConvertTo-TlsCertificateResult {
<#
.SYNOPSIS
    Builds a stable output object for certificate retrieval results.
 
.OUTPUTS
    PSCustomObject
#>

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

        [Parameter(Mandatory)]
        [ValidateRange(1,65535)]
        [int]$Port,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$TargetHost,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [pscustomobject]$Validity,

        [System.Security.Authentication.SslProtocols]$NegotiatedProtocol,

        $CipherAlgorithm,

        [int]$CipherStrength,

        [timespan]$Elapsed = [timespan]::Zero
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Target=$Hostname :$Port, TargetHost=$TargetHost)"

    try {
        $result = [PSCustomObject]@{
            PSTypeName         = 'TLSleuth.CertificateResult'
            Hostname           = $Hostname
            Port               = $Port
            TargetHost         = $TargetHost
            Subject            = $Certificate.Subject
            Issuer             = $Certificate.Issuer
            Thumbprint         = $Certificate.Thumbprint
            SerialNumber       = $Certificate.SerialNumber
            NotBefore          = $Certificate.NotBefore
            NotAfter           = $Certificate.NotAfter
            IsValidNow         = $Validity.IsValidNow
            DaysUntilExpiry    = $Validity.DaysUntilExpiry
            NegotiatedProtocol = $NegotiatedProtocol
            CipherAlgorithm    = $CipherAlgorithm
            CipherStrength     = $CipherStrength
            ElapsedMs          = [int][Math]::Round($Elapsed.TotalMilliseconds)
            Certificate        = $Certificate
        }

        Write-Verbose "[$fn] Built result for Subject='$($Certificate.Subject)' with protocol '$NegotiatedProtocol'."
        $result
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\ConvertTo-TlsCertificateResult.ps1' 73
#Region '.\private\ConvertTo-TlsProtocolOptions.ps1' -1

function ConvertTo-TlsProtocolOptions {
<#
.SYNOPSIS
    Converts user protocol names into an SslProtocols flag enum.
 
.OUTPUTS
    System.Security.Authentication.SslProtocols
#>

    [CmdletBinding()]
    [OutputType([System.Security.Authentication.SslProtocols])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string[]]$TlsProtocols
    )

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

    try {
        $map = @{
            SystemDefault = [System.Security.Authentication.SslProtocols]::None
            Ssl3          = [System.Security.Authentication.SslProtocols]::Ssl3
            Tls           = [System.Security.Authentication.SslProtocols]::Tls
            Tls11         = [System.Security.Authentication.SslProtocols]::Tls11
            Tls12         = [System.Security.Authentication.SslProtocols]::Tls12
            Tls13         = [System.Security.Authentication.SslProtocols]::Tls13
        }

        $result = [System.Security.Authentication.SslProtocols]::None
        foreach ($name in $TlsProtocols) {
            if (-not $map.ContainsKey($name)) {
                throw [System.ArgumentException]::new("Unsupported TLS protocol value: $name")
            }

            if ($name -eq 'SystemDefault') {
                if ($TlsProtocols.Count -gt 1) {
                    throw [System.ArgumentException]::new('SystemDefault cannot be combined with explicit protocol values.')
                }

                Write-Verbose "[$fn] Using SystemDefault TLS policy."
                return [System.Security.Authentication.SslProtocols]::None
            }

            $result = $result -bor $map[$name]
        }

        Write-Verbose "[$fn] Resolved protocols: $result"
        $result
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\ConvertTo-TlsProtocolOptions.ps1' 57
#Region '.\private\Get-RemoteCertificate.ps1' -1

function Get-RemoteCertificate {
<#
.SYNOPSIS
    Extracts the remote certificate from an authenticated SslStream.
 
.OUTPUTS
    System.Security.Cryptography.X509Certificates.X509Certificate2
#>

    [CmdletBinding()]
    [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [System.Net.Security.SslStream]$SslStream
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (IsAuthenticated=$($SslStream.IsAuthenticated))"

    try {
        if (-not $SslStream.RemoteCertificate) {
            throw [System.InvalidOperationException]::new('Remote endpoint did not provide a certificate.')
        }

        $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($SslStream.RemoteCertificate)
        Write-Verbose "[$fn] Retrieved certificate Subject='$($certificate.Subject)'"
        $certificate
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Get-RemoteCertificate.ps1' 35
#Region '.\private\Invoke-SmtpStartTlsNegotiation.ps1' -1

function Invoke-SmtpStartTlsNegotiation {
<#
.SYNOPSIS
    Performs SMTP STARTTLS negotiation over an existing plaintext stream.
 
.OUTPUTS
    PSCustomObject
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [System.IO.Stream]$NetworkStream,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$EhloName,

        [ValidateRange(1000,600000)]
        [int]$TimeoutMs = 10000
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (EhloName=$EhloName, TimeoutMs=$TimeoutMs)"

    if (-not $NetworkStream.CanRead -or -not $NetworkStream.CanWrite) {
        throw [System.InvalidOperationException]::new('SMTP STARTTLS negotiation requires a readable and writable stream.')
    }

    function Read-SmtpLine {
        param(
            [Parameter(Mandatory)]
            [System.IO.Stream]$Stream,

            [Parameter(Mandatory)]
            [int]$ReadTimeoutMs
        )

        $bytes = [System.Collections.Generic.List[byte]]::new()
        $buffer = New-Object byte[] 1

        while ($true) {
            try {
                $read = $Stream.Read($buffer, 0, 1)
            }
            catch [System.IO.IOException] {
                $inner = $_.Exception.InnerException
                if ($inner -is [System.Net.Sockets.SocketException] -and
                    $inner.SocketErrorCode -eq [System.Net.Sockets.SocketError]::TimedOut) {
                    throw [System.TimeoutException]::new("SMTP negotiation timed out after ${ReadTimeoutMs}ms.")
                }
                throw
            }

            if ($read -eq 0) {
                throw [System.IO.EndOfStreamException]::new('SMTP server closed the connection unexpectedly.')
            }

            $b = $buffer[0]
            if ($b -eq 10) {
                break
            }

            if ($b -ne 13) {
                $bytes.Add($b)
            }

            if ($bytes.Count -gt 4096) {
                throw [System.InvalidOperationException]::new('SMTP response line exceeded 4096 bytes.')
            }
        }

        [System.Text.Encoding]::ASCII.GetString($bytes.ToArray())
    }

    function Read-SmtpResponse {
        param(
            [Parameter(Mandatory)]
            [System.IO.Stream]$Stream,

            [Parameter(Mandatory)]
            [int]$ReadTimeoutMs
        )

        $lines = [System.Collections.Generic.List[string]]::new()
        $firstLine = Read-SmtpLine -Stream $Stream -ReadTimeoutMs $ReadTimeoutMs
        $lines.Add($firstLine)

        $statusCode = 0
        if ($firstLine.Length -lt 3 -or -not [int]::TryParse($firstLine.Substring(0, 3), [ref]$statusCode)) {
            throw [System.InvalidOperationException]::new("Invalid SMTP response line: '$firstLine'")
        }

        $statusPrefix = '{0:D3}' -f $statusCode
        $isMultiline = ($firstLine.Length -ge 4 -and $firstLine[3] -eq '-')

        if ($isMultiline) {
            while ($true) {
                $line = Read-SmtpLine -Stream $Stream -ReadTimeoutMs $ReadTimeoutMs
                $lines.Add($line)

                if ($line.Length -ge 4 -and $line.StartsWith($statusPrefix) -and $line[3] -eq '-') {
                    continue
                }

                if ($line.Length -ge 4 -and $line.StartsWith($statusPrefix) -and $line[3] -eq ' ') {
                    break
                }

                throw [System.InvalidOperationException]::new("Invalid SMTP multiline response continuation: '$line'")
            }
        }

        [PSCustomObject]@{
            Code    = $statusCode
            Lines   = [string[]]$lines
            Message = ($lines -join "`n")
        }
    }

    function Send-SmtpCommand {
        param(
            [Parameter(Mandatory)]
            [System.IO.Stream]$Stream,

            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string]$Command
        )

        $payload = [System.Text.Encoding]::ASCII.GetBytes("$Command`r`n")
        $Stream.Write($payload, 0, $payload.Length)
        $Stream.Flush()
    }

    $originalReadTimeout = $null
    $originalWriteTimeout = $null
    $timeoutsApplied = $false

    try {
        if ($NetworkStream.CanTimeout) {
            $originalReadTimeout = $NetworkStream.ReadTimeout
            $originalWriteTimeout = $NetworkStream.WriteTimeout

            $NetworkStream.ReadTimeout = $TimeoutMs
            $NetworkStream.WriteTimeout = $TimeoutMs
            $timeoutsApplied = $true
        }

        $banner = Read-SmtpResponse -Stream $NetworkStream -ReadTimeoutMs $TimeoutMs
        if ($banner.Code -ne 220) {
            throw [System.InvalidOperationException]::new("SMTP server did not return 220 greeting. Received: $($banner.Message)")
        }
        Write-Verbose "[$fn] Received SMTP greeting code $($banner.Code)."

        Send-SmtpCommand -Stream $NetworkStream -Command "EHLO $EhloName"
        $ehloResponse = Read-SmtpResponse -Stream $NetworkStream -ReadTimeoutMs $TimeoutMs
        if ($ehloResponse.Code -ne 250) {
            throw [System.InvalidOperationException]::new("SMTP EHLO failed. Received: $($ehloResponse.Message)")
        }
        Write-Verbose "[$fn] EHLO accepted with code $($ehloResponse.Code)."

        $supportsStartTls = $false
        foreach ($line in $ehloResponse.Lines) {
            if ($line.Length -lt 4) { continue }
            $capability = $line.Substring(4).Trim()
            if ($capability -match '^(?i)STARTTLS(?:\s|$)') {
                $supportsStartTls = $true
                break
            }
        }

        if (-not $supportsStartTls) {
            throw [System.InvalidOperationException]::new('SMTP server does not advertise STARTTLS in EHLO response.')
        }

        Send-SmtpCommand -Stream $NetworkStream -Command 'STARTTLS'
        $startTlsResponse = Read-SmtpResponse -Stream $NetworkStream -ReadTimeoutMs $TimeoutMs
        if ($startTlsResponse.Code -ne 220) {
            throw [System.InvalidOperationException]::new("SMTP STARTTLS command was not accepted. Received: $($startTlsResponse.Message)")
        }
        Write-Verbose "[$fn] STARTTLS accepted with code $($startTlsResponse.Code)."

        [PSCustomObject]@{
            GreetingCode = $banner.Code
            EhloCode     = $ehloResponse.Code
            StartTlsCode = $startTlsResponse.Code
        }
    }
    catch {
        Write-Debug "[$fn] STARTTLS negotiation failed for EHLO name '$EhloName': $($_.Exception.GetType().FullName)"
        throw
    }
    finally {
        if ($timeoutsApplied) {
            try { $NetworkStream.ReadTimeout = $originalReadTimeout } catch {}
            try { $NetworkStream.WriteTimeout = $originalWriteTimeout } catch {}
        }
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Invoke-SmtpStartTlsNegotiation.ps1' 204
#Region '.\private\Invoke-WithRetry.ps1' -1

function Invoke-WithRetry {
<#
.SYNOPSIS
    Invokes a script block with bounded retry behavior for transient operations.
 
.OUTPUTS
    Any output from the script block.
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [scriptblock]$ScriptBlock,

        [ValidateRange(1,10)]
        [int]$MaxAttempts = 3,

        [ValidateRange(0,60000)]
        [int]$DelayMs = 250,

        [string[]]$RetryOnExceptionType = @(
            'System.TimeoutException',
            'System.Net.Sockets.SocketException',
            'System.IO.IOException'
        )
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (MaxAttempts=$MaxAttempts, DelayMs=$DelayMs)"

    try {
        $attempt = 0
        while ($true) {
            $attempt++
            try {
                Write-Verbose "[$fn] Attempt $attempt of $MaxAttempts."
                return & $ScriptBlock
            }
            catch {
                $exceptionType = $_.Exception.GetType().FullName
                $canRetry = ($attempt -lt $MaxAttempts) -and ($RetryOnExceptionType -contains $exceptionType)
                if (-not $canRetry) {
                    Write-Verbose "[$fn] Stopping retries after $exceptionType on attempt $attempt."
                    throw
                }

                Write-Verbose "[$fn] Retrying after $exceptionType (attempt $attempt of $MaxAttempts)."
                Write-Debug "[$fn] Retrying after $exceptionType (attempt $attempt of $MaxAttempts)."
                if ($DelayMs -gt 0) {
                    Start-Sleep -Milliseconds $DelayMs
                }
            }
        }
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Invoke-WithRetry.ps1' 61
#Region '.\private\Start-TlsHandshake.ps1' -1

function Start-TlsHandshake {
<#
.SYNOPSIS
    Starts a TLS handshake on an existing network stream.
 
.OUTPUTS
    PSCustomObject { SslStream, NegotiatedProtocol, CipherAlgorithm, CipherStrength }
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [System.IO.Stream]$NetworkStream,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$TargetHost,

        [Parameter(Mandatory)]
        [System.Security.Authentication.SslProtocols]$SslProtocols,

        [ValidateRange(1000,600000)]
        [int]$TimeoutMs = 10000,

        [switch]$SkipCertificateValidation
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Target=$TargetHost, Protocols=$SslProtocols, TimeoutMs=$TimeoutMs, SkipValidation=$SkipCertificateValidation)"
    $ssl = $null

    $validationCallback = $null
    if ($SkipCertificateValidation) {
        if (-not ('TLSleuth.CertificateValidationCallbacks' -as [type])) {
            Add-Type -TypeDefinition @"
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
 
namespace TLSleuth
{
    public static class CertificateValidationCallbacks
    {
        public static bool AcceptAll(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
        {
            return true;
        }
    }
}
"@

        }
        $validationCallback = [System.Net.Security.RemoteCertificateValidationCallback][TLSleuth.CertificateValidationCallbacks]::AcceptAll
    }

    try {
        $ssl = [System.Net.Security.SslStream]::new($NetworkStream, $false, $validationCallback)
        $task = $ssl.AuthenticateAsClientAsync($TargetHost, $null, $SslProtocols, $false)

        if (-not $task.Wait($TimeoutMs)) {
            throw [System.TimeoutException]::new("TLS handshake timeout after ${TimeoutMs}ms for $TargetHost")
        }

        Write-Verbose "[$fn] Handshake succeeded (Protocol=$($ssl.SslProtocol), Cipher=$($ssl.CipherAlgorithm), Strength=$($ssl.CipherStrength))."
        [PSCustomObject]@{
            SslStream          = $ssl
            NegotiatedProtocol = $ssl.SslProtocol
            CipherAlgorithm    = $ssl.CipherAlgorithm
            CipherStrength     = $ssl.CipherStrength
        }
    }
    catch {
        Write-Debug "[$fn] Handshake failed for ${TargetHost}: $($_.Exception.GetType().FullName)"
        try { if ($ssl) { $ssl.Dispose() } } catch {}

        $errorToThrow = $_.Exception
        if ($errorToThrow -is [System.AggregateException] -and $errorToThrow.InnerException) {
            throw $errorToThrow.InnerException
        }
        throw
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Start-TlsHandshake.ps1' 86
#Region '.\private\Test-TlsCertificateValidity.ps1' -1

function Test-TlsCertificateValidity {
<#
.SYNOPSIS
    Evaluates date-based validity of an X509 certificate.
 
.OUTPUTS
    PSCustomObject { IsValidNow, NotBefore, NotAfter, DaysUntilExpiry }
#>

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

        [datetime]$AsOf = (Get-Date)
    )

    $fn = $MyInvocation.MyCommand.Name
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    Write-Verbose "[$fn] Begin (Subject=$($Certificate.Subject), AsOf=$AsOf)"

    try {
        $notBefore = $Certificate.NotBefore
        $notAfter = $Certificate.NotAfter
        $isValid = ($AsOf -ge $notBefore) -and ($AsOf -le $notAfter)
        $daysUntilExpiry = [int][Math]::Floor(($notAfter - $AsOf).TotalDays)

        Write-Verbose "[$fn] Validity computed (IsValidNow=$isValid, DaysUntilExpiry=$daysUntilExpiry)."
        [PSCustomObject]@{
            IsValidNow      = $isValid
            NotBefore       = $notBefore
            NotAfter        = $notAfter
            DaysUntilExpiry = $daysUntilExpiry
        }
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Test-TlsCertificateValidity.ps1' 41
#Region '.\public\Get-TLSleuthCertificate.ps1' -1

function Get-TLSleuthCertificate {

[CmdletBinding()]
[OutputType([pscustomobject])]

    param(
        # Accepts strings directly from the pipeline AND by matching property name
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Host','DnsName','ComputerName','Target','Name')]
        [ValidateNotNullOrEmpty()]
        [string]$Hostname,

        # Accepts values from objects with a matching property name in the pipeline
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(1,65535)]
        [int]$Port = 443,

        # Accepts values from objects with a matching property name in the pipeline
        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('SNI','ServerName')]
        [string]$TargetHost,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateSet('ImplicitTls','SmtpStartTls')]
        [string]$Transport = 'ImplicitTls',

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('EhloName','ClientName')]
        [string]$SmtpEhloName,

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

        [ValidateRange(1,600)]
        [int]$TimeoutSec = 10,

        [switch]$SkipCertificateValidation

    )

    begin {
        $fn = $MyInvocation.MyCommand.Name
        $pipelineSw = [System.Diagnostics.Stopwatch]::StartNew()
        $processed = 0
        $timeoutMs = $TimeoutSec * 1000

        # foreach ($requiredFunction in @(
        # 'ConvertTo-TlsProtocolOptions',
        # 'Connect-TcpWithTimeout',
        # 'Invoke-SmtpStartTlsNegotiation',
        # 'Start-TlsHandshake',
        # 'Get-RemoteCertificate',
        # 'Test-TlsCertificateValidity',
        # 'ConvertTo-TlsCertificateResult',
        # 'Close-NetworkResources',
        # 'Invoke-WithRetry'
        # )) {
        # if (-not (Get-Command -Name $requiredFunction -ErrorAction SilentlyContinue)) {
        # $helperPath = Join-Path (Join-Path $PSScriptRoot '..\private') "$requiredFunction.ps1"
        # if (-not (Test-Path -Path $helperPath)) {
        # throw "Required helper function '$requiredFunction' was not found at path '$helperPath'."
        # }

        # . $helperPath
        # }
        # }

        $sslProtocols = ConvertTo-TlsProtocolOptions -TlsProtocols $TlsProtocols
        Write-Verbose "[$fn] Begin (Transport=$Transport, TimeoutSec=$TimeoutSec, Protocols=$($TlsProtocols -join ','))"

    }

    process {
        $itemSw = [System.Diagnostics.Stopwatch]::StartNew()
        $processed++

        $target = if ([string]::IsNullOrWhiteSpace($TargetHost)) { $Hostname } else { $TargetHost }

        $tcpConnection = $null
        $tlsSession = $null
        $certificate = $null

        try {
            $tcpConnection = Invoke-WithRetry -ScriptBlock {
                Connect-TcpWithTimeout -Hostname $Hostname -Port $Port -TimeoutMs $timeoutMs
            }

            if ($Transport -eq 'SmtpStartTls') {
                $ehloName = $SmtpEhloName
                if ([string]::IsNullOrWhiteSpace($ehloName)) {
                    $ehloName = [System.Net.Dns]::GetHostName()
                    if ([string]::IsNullOrWhiteSpace($ehloName)) {
                        $ehloName = 'localhost'
                    }
                }

                Invoke-SmtpStartTlsNegotiation `
                    -NetworkStream $tcpConnection.NetworkStream `
                    -EhloName $ehloName `
                    -TimeoutMs $timeoutMs | Out-Null
            }

            $tlsSession = Start-TlsHandshake `
                -NetworkStream $tcpConnection.NetworkStream `
                -TargetHost $target `
                -SslProtocols $sslProtocols `
                -TimeoutMs $timeoutMs `
                -SkipCertificateValidation:$SkipCertificateValidation

            $certificate = Get-RemoteCertificate -SslStream $tlsSession.SslStream

            $validity = Test-TlsCertificateValidity -Certificate $certificate

            ConvertTo-TlsCertificateResult `
                -Hostname $Hostname `
                -Port $Port `
                -TargetHost $target `
                -Certificate $certificate `
                -Validity $validity `
                -NegotiatedProtocol $tlsSession.NegotiatedProtocol `
                -CipherAlgorithm $tlsSession.CipherAlgorithm `
                -CipherStrength $tlsSession.CipherStrength `
                -Elapsed $itemSw.Elapsed
        }
        finally {
            $itemSw.Stop()
            Close-NetworkResources -SslStream $tlsSession.SslStream -NetworkStream $tcpConnection.NetworkStream -TcpClient $tcpConnection.TcpClient
        }

    }

    end {
        $pipelineSw.Stop()
        Write-Verbose "[$($MyInvocation.MyCommand.Name)] Complete (Processed=$processed) in $($pipelineSw.Elapsed)"
    }
}
#EndRegion '.\public\Get-TLSleuthCertificate.ps1' 137