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,

        [bool]$CertificateValidationPassed = $true,

        [System.Net.Security.SslPolicyErrors]$CertificatePolicyErrors = [System.Net.Security.SslPolicyErrors]::None,

        [string[]]$CertificatePolicyErrorFlags = @(),

        [string[]]$CertificateChainStatus = @()
    )

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

    try {
        $policyErrorFlags = if ($null -eq $CertificatePolicyErrorFlags) { ,([string[]]@()) } else { ,([string[]]$CertificatePolicyErrorFlags) }
        $chainStatus = if ($null -eq $CertificateChainStatus) { ,([string[]]@()) } else { ,([string[]]$CertificateChainStatus) }

        $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
            CertificateValidationPassed = $CertificateValidationPassed
            CertificatePolicyErrors     = $CertificatePolicyErrors
            CertificatePolicyErrorFlags = $policyErrorFlags
            CertificateChainStatus      = $chainStatus
            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' 88
#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-ImapStartTlsNegotiation.ps1' -1

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

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

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

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

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

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

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

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

        $lines = [System.Collections.Generic.List[string]]::new()
        $completionLine = $null
        $status = $null

        while ($true) {
            $line = Read-TextProtocolLine -Stream $Stream -ReadTimeoutMs $ReadTimeoutMs -ProtocolName 'IMAP'
            $lines.Add($line)

            if ($line.StartsWith("$Tag ", [System.StringComparison]::OrdinalIgnoreCase)) {
                $completionLine = $line
                $tail = $line.Substring($Tag.Length).TrimStart()

                if ($tail -notmatch '^(?<status>[A-Za-z]+)(?:\s+(?<text>.*))?$') {
                    throw [System.InvalidOperationException]::new("Invalid IMAP tagged completion line: '$line'")
                }

                $status = $matches['status'].ToUpperInvariant()
                break
            }
        }

        [PSCustomObject]@{
            Tag            = $Tag
            Status         = $status
            Lines          = [string[]]$lines
            CompletionLine = $completionLine
            Message        = ($lines -join "`n")
        }
    }

    try {
        Invoke-WithStreamTimeout -Stream $NetworkStream -TimeoutMs $TimeoutMs -ScriptBlock {
            $greetingLine = Read-TextProtocolLine -Stream $NetworkStream -ReadTimeoutMs $TimeoutMs -ProtocolName 'IMAP'
            if ($greetingLine -notmatch '^\*\s+(?<status>[A-Za-z]+)\b') {
                throw [System.InvalidOperationException]::new("Invalid IMAP greeting line: '$greetingLine'")
            }

            $greetingStatus = $matches['status'].ToUpperInvariant()
            if ($greetingStatus -ne 'OK' -and $greetingStatus -ne 'PREAUTH') {
                throw [System.InvalidOperationException]::new("IMAP server did not return OK or PREAUTH greeting. Received: $greetingLine")
            }
            Write-Verbose "[$fn] Received IMAP greeting status $greetingStatus."

            $capabilityTag = 'A001'
            Send-TextProtocolCommand -Stream $NetworkStream -Command "$capabilityTag CAPABILITY"
            $capabilityResponse = Read-ImapTaggedResponse -Stream $NetworkStream -Tag $capabilityTag -ReadTimeoutMs $TimeoutMs
            if ($capabilityResponse.Status -ne 'OK') {
                throw [System.InvalidOperationException]::new("IMAP CAPABILITY command failed. Received: $($capabilityResponse.Message)")
            }
            Write-Verbose "[$fn] CAPABILITY completed with status $($capabilityResponse.Status)."

            $supportsStartTls = $false
            foreach ($line in $capabilityResponse.Lines) {
                if ($line -notmatch '^\*\s+CAPABILITY\s+(?<caps>.+)$') { continue }
                $capabilities = $matches['caps'].Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
                foreach ($capability in $capabilities) {
                    if ($capability.Equals('STARTTLS', [System.StringComparison]::OrdinalIgnoreCase)) {
                        $supportsStartTls = $true
                        break
                    }
                }
                if ($supportsStartTls) { break }
            }

            if (-not $supportsStartTls -and $capabilityResponse.CompletionLine -match '\[CAPABILITY\s+(?<caps>[^\]]+)\]') {
                $capsInCode = $matches['caps'].Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
                foreach ($capability in $capsInCode) {
                    if ($capability.Equals('STARTTLS', [System.StringComparison]::OrdinalIgnoreCase)) {
                        $supportsStartTls = $true
                        break
                    }
                }
            }

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

            $startTlsTag = 'A002'
            Send-TextProtocolCommand -Stream $NetworkStream -Command "$startTlsTag STARTTLS"
            $startTlsResponse = Read-ImapTaggedResponse -Stream $NetworkStream -Tag $startTlsTag -ReadTimeoutMs $TimeoutMs
            if ($startTlsResponse.Status -ne 'OK') {
                throw [System.InvalidOperationException]::new("IMAP STARTTLS command was not accepted. Received: $($startTlsResponse.Message)")
            }
            Write-Verbose "[$fn] STARTTLS accepted with status $($startTlsResponse.Status)."

            [PSCustomObject]@{
                GreetingStatus   = $greetingStatus
                CapabilityStatus = $capabilityResponse.Status
                StartTlsStatus   = $startTlsResponse.Status
            }
        }
    }
    catch {
        Write-Debug "[$fn] STARTTLS negotiation failed: $($_.Exception.GetType().FullName)"
        throw
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Invoke-ImapStartTlsNegotiation.ps1' 142
#Region '.\private\Invoke-Pop3StartTlsNegotiation.ps1' -1

function Invoke-Pop3StartTlsNegotiation {
<#
.SYNOPSIS
    Performs POP3 STLS negotiation over an existing plaintext stream.
 
.OUTPUTS
    PSCustomObject
#>

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

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

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

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

    function Parse-Pop3StatusLine {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string]$Line
        )

        if ($Line.StartsWith('+OK', [System.StringComparison]::OrdinalIgnoreCase)) {
            return [PSCustomObject]@{
                IsOk    = $true
                Status  = '+OK'
                Message = $Line
            }
        }

        if ($Line.StartsWith('-ERR', [System.StringComparison]::OrdinalIgnoreCase)) {
            return [PSCustomObject]@{
                IsOk    = $false
                Status  = '-ERR'
                Message = $Line
            }
        }

        throw [System.InvalidOperationException]::new("Invalid POP3 status line: '$Line'")
    }

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

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

        $lines = [System.Collections.Generic.List[string]]::new()

        while ($true) {
            $line = Read-TextProtocolLine -Stream $Stream -ReadTimeoutMs $ReadTimeoutMs -ProtocolName 'POP3'
            if ($line -eq '.') {
                break
            }

            # POP3 dot-stuffing: leading '..' represents literal '.'
            if ($line.StartsWith('..', [System.StringComparison]::Ordinal)) {
                $line = $line.Substring(1)
            }

            $lines.Add($line)
        }

        [string[]]$lines
    }

    try {
        Invoke-WithStreamTimeout -Stream $NetworkStream -TimeoutMs $TimeoutMs -ScriptBlock {
            $greetingLine = Read-TextProtocolLine -Stream $NetworkStream -ReadTimeoutMs $TimeoutMs -ProtocolName 'POP3'
            $greeting = Parse-Pop3StatusLine -Line $greetingLine
            if (-not $greeting.IsOk) {
                throw [System.InvalidOperationException]::new("POP3 server did not return +OK greeting. Received: $($greeting.Message)")
            }
            Write-Verbose "[$fn] Received POP3 greeting status $($greeting.Status)."

            Send-TextProtocolCommand -Stream $NetworkStream -Command 'CAPA'
            $capaStatusLine = Read-TextProtocolLine -Stream $NetworkStream -ReadTimeoutMs $TimeoutMs -ProtocolName 'POP3'
            $capaStatus = Parse-Pop3StatusLine -Line $capaStatusLine
            if (-not $capaStatus.IsOk) {
                throw [System.InvalidOperationException]::new("POP3 CAPA command failed. Received: $($capaStatus.Message)")
            }
            $capabilityLines = Read-Pop3MultilineData -Stream $NetworkStream -ReadTimeoutMs $TimeoutMs
            Write-Verbose "[$fn] CAPA accepted with status $($capaStatus.Status)."

            $supportsStls = $false
            foreach ($capability in $capabilityLines) {
                if ($capability -match '^(?i)STLS(?:\s|$)') {
                    $supportsStls = $true
                    break
                }
            }

            if (-not $supportsStls) {
                throw [System.InvalidOperationException]::new('POP3 server does not advertise STLS in CAPA response.')
            }

            Send-TextProtocolCommand -Stream $NetworkStream -Command 'STLS'
            $stlsStatusLine = Read-TextProtocolLine -Stream $NetworkStream -ReadTimeoutMs $TimeoutMs -ProtocolName 'POP3'
            $stlsStatus = Parse-Pop3StatusLine -Line $stlsStatusLine
            if (-not $stlsStatus.IsOk) {
                throw [System.InvalidOperationException]::new("POP3 STLS command was not accepted. Received: $($stlsStatus.Message)")
            }
            Write-Verbose "[$fn] STLS accepted with status $($stlsStatus.Status)."

            [PSCustomObject]@{
                GreetingStatus = $greeting.Status
                CapaStatus     = $capaStatus.Status
                StlsStatus     = $stlsStatus.Status
            }
        }
    }
    catch {
        Write-Debug "[$fn] STLS negotiation failed: $($_.Exception.GetType().FullName)"
        throw
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Invoke-Pop3StartTlsNegotiation.ps1' 135
#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-SmtpResponse {
        param(
            [Parameter(Mandatory)]
            [System.IO.Stream]$Stream,

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

        $lines = [System.Collections.Generic.List[string]]::new()
        $firstLine = Read-TextProtocolLine -Stream $Stream -ReadTimeoutMs $ReadTimeoutMs -ProtocolName 'SMTP'
        $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-TextProtocolLine -Stream $Stream -ReadTimeoutMs $ReadTimeoutMs -ProtocolName 'SMTP'
                $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")
        }
    }

    try {
        Invoke-WithStreamTimeout -Stream $NetworkStream -TimeoutMs $TimeoutMs -ScriptBlock {
            $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-TextProtocolCommand -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-TextProtocolCommand -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 {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Invoke-SmtpStartTlsNegotiation.ps1' 128
#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\Invoke-WithStreamTimeout.ps1' -1

function Invoke-WithStreamTimeout {
<#
.SYNOPSIS
    Temporarily applies stream read/write timeouts while invoking a script block.
#>

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

        [Parameter(Mandatory)]
        [ValidateRange(1000,600000)]
        [int]$TimeoutMs,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [scriptblock]$ScriptBlock
    )

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

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

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

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

        & $ScriptBlock
    }
    finally {
        if ($timeoutsApplied) {
            try { $Stream.ReadTimeout = $originalReadTimeout } catch {}
            try { $Stream.WriteTimeout = $originalWriteTimeout } catch {}
        }

        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Invoke-WithStreamTimeout.ps1' 51
#Region '.\private\Read-TextProtocolLine.ps1' -1

function Read-TextProtocolLine {
<#
.SYNOPSIS
    Reads one ASCII CRLF-terminated line from a text protocol stream.
#>

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

        [Parameter(Mandatory)]
        [ValidateRange(1,600000)]
        [int]$ReadTimeoutMs,

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

        [ValidateRange(1,65535)]
        [int]$MaxLineBytes = 4096
    )

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

    try {
        $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("$ProtocolName negotiation timed out after ${ReadTimeoutMs}ms.")
                }
                throw
            }

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

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

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

            if ($bytes.Count -gt $MaxLineBytes) {
                throw [System.InvalidOperationException]::new("$ProtocolName response line exceeded $MaxLineBytes bytes.")
            }
        }

        $line = [System.Text.Encoding]::ASCII.GetString($bytes.ToArray())
        Write-Verbose "[$fn] Read: $line"

        $line
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Read-TextProtocolLine.ps1' 73
#Region '.\private\Send-TextProtocolCommand.ps1' -1

function Send-TextProtocolCommand {
<#
.SYNOPSIS
    Sends one ASCII command line terminated with CRLF.
#>

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

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

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

    try {
        $payload = [System.Text.Encoding]::ASCII.GetBytes("$Command`r`n")
        $Stream.Write($payload, 0, $payload.Length)
        $Stream.Flush()
        Write-Verbose "[$fn] Sent: $Command"
    }
    finally {
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Send-TextProtocolCommand.ps1' 32
#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,
        CertificateValidationPassed, CertificatePolicyErrors, CertificatePolicyErrorFlags, CertificateChainStatus
    }
#>

    [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

    if (-not ('TLSleuth.CertificateValidationCallbacksV2' -as [type])) {
        Add-Type -TypeDefinition @"
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Security;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
 
namespace TLSleuth
{
    public sealed class CertificateValidationStateV2
    {
        public SslPolicyErrors PolicyErrors { get; set; }
        public string[] ChainStatus { get; set; } = new string[0];
    }
 
    public static class CertificateValidationCallbacksV2
    {
        private static readonly ConcurrentDictionary<int, bool> SkipValidationBySender = new ConcurrentDictionary<int, bool>();
        private static readonly ConcurrentDictionary<int, CertificateValidationStateV2> StateBySender = new ConcurrentDictionary<int, CertificateValidationStateV2>();
 
        private static int GetSenderId(object sender)
        {
            return sender == null ? 0 : RuntimeHelpers.GetHashCode(sender);
        }
 
        public static void Register(object sender, bool skipValidation)
        {
            var id = GetSenderId(sender);
            SkipValidationBySender[id] = skipValidation;
            CertificateValidationStateV2 removed;
            StateBySender.TryRemove(id, out removed);
        }
 
        public static CertificateValidationStateV2 GetState(object sender)
        {
            var id = GetSenderId(sender);
            CertificateValidationStateV2 state;
            StateBySender.TryGetValue(id, out state);
            return state;
        }
 
        public static void Cleanup(object sender)
        {
            var id = GetSenderId(sender);
            bool removedSkip;
            SkipValidationBySender.TryRemove(id, out removedSkip);
            CertificateValidationStateV2 removedState;
            StateBySender.TryRemove(id, out removedState);
        }
 
        public static bool CaptureAndValidate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
        {
            var chainStatuses = new List<string>();
            if (chain != null && chain.ChainStatus != null)
            {
                foreach (var status in chain.ChainStatus)
                {
                    if (status.Status != X509ChainStatusFlags.NoError)
                    {
                        chainStatuses.Add(status.Status.ToString());
                    }
                }
            }
 
            var id = GetSenderId(sender);
            StateBySender[id] = new CertificateValidationStateV2
            {
                PolicyErrors = sslPolicyErrors,
                ChainStatus = chainStatuses.ToArray()
            };
 
            bool skipValidation;
            if (!SkipValidationBySender.TryGetValue(id, out skipValidation))
            {
                skipValidation = false;
            }
 
            return skipValidation || sslPolicyErrors == SslPolicyErrors.None;
        }
    }
}
"@

    }

    try {
        $ssl = [System.Net.Security.SslStream]::new(
            $NetworkStream,
            $false,
            [System.Net.Security.RemoteCertificateValidationCallback][TLSleuth.CertificateValidationCallbacksV2]::CaptureAndValidate
        )

        [TLSleuth.CertificateValidationCallbacksV2]::Register($ssl, [bool]$SkipCertificateValidation)

        $task = $ssl.AuthenticateAsClientAsync($TargetHost, $null, $SslProtocols, $false)

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

        $validationState = [TLSleuth.CertificateValidationCallbacksV2]::GetState($ssl)
        $policyErrors = [System.Net.Security.SslPolicyErrors]::None
        $chainStatus = [string[]]@()
        if ($validationState) {
            $policyErrors = $validationState.PolicyErrors
            $chainStatus = [string[]]$validationState.ChainStatus
        }

        $policyErrorFlags = [System.Collections.Generic.List[string]]::new()
        if (($policyErrors -band [System.Net.Security.SslPolicyErrors]::RemoteCertificateNotAvailable) -ne 0) {
            $policyErrorFlags.Add('RemoteCertificateNotAvailable')
        }
        if (($policyErrors -band [System.Net.Security.SslPolicyErrors]::RemoteCertificateNameMismatch) -ne 0) {
            $policyErrorFlags.Add('RemoteCertificateNameMismatch')
        }
        if (($policyErrors -band [System.Net.Security.SslPolicyErrors]::RemoteCertificateChainErrors) -ne 0) {
            $policyErrorFlags.Add('RemoteCertificateChainErrors')
        }

        $validationPassed = ($policyErrors -eq [System.Net.Security.SslPolicyErrors]::None)

        Write-Verbose "[$fn] Handshake succeeded (Protocol=$($ssl.SslProtocol), Cipher=$($ssl.CipherAlgorithm), Strength=$($ssl.CipherStrength), ValidationPassed=$validationPassed, PolicyErrors=$policyErrors)."
        [PSCustomObject]@{
            SslStream                    = $ssl
            NegotiatedProtocol           = $ssl.SslProtocol
            CipherAlgorithm              = $ssl.CipherAlgorithm
            CipherStrength               = $ssl.CipherStrength
            CertificateValidationPassed  = $validationPassed
            CertificatePolicyErrors      = $policyErrors
            CertificatePolicyErrorFlags  = [string[]]$policyErrorFlags
            CertificateChainStatus       = [string[]]$chainStatus
        }
    }
    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 {
        try {
            if ($ssl) {
                [TLSleuth.CertificateValidationCallbacksV2]::Cleanup($ssl)
            }
        }
        catch {}
        $sw.Stop()
        Write-Verbose "[$fn] Complete in $($sw.Elapsed)"
    }
}
#EndRegion '.\private\Start-TlsHandshake.ps1' 191
#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','ImapStartTls','Pop3StartTls')]
        [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 = $true

    )

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

        $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
            }
            elseif ($Transport -eq 'ImapStartTls') {
                Invoke-ImapStartTlsNegotiation `
                    -NetworkStream $tcpConnection.NetworkStream `
                    -TimeoutMs $timeoutMs | Out-Null
            }
            elseif ($Transport -eq 'Pop3StartTls') {
                Invoke-Pop3StartTlsNegotiation `
                    -NetworkStream $tcpConnection.NetworkStream `
                    -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 `
                -CertificateValidationPassed $tlsSession.CertificateValidationPassed `
                -CertificatePolicyErrors $tlsSession.CertificatePolicyErrors `
                -CertificatePolicyErrorFlags $tlsSession.CertificatePolicyErrorFlags `
                -CertificateChainStatus $tlsSession.CertificateChainStatus `
                -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' 130