TLSleuth.psm1
|
#Region '.\private\Close-NetworkResources.ps1' -1 function Close-NetworkResources { <# .SYNOPSIS Safely disposes network resources used during TLS operations. #> [CmdletBinding()] param( [psobject]$Connection ) $fn = $MyInvocation.MyCommand.Name $sw = [System.Diagnostics.Stopwatch]::StartNew() $sslStream = $null $networkStream = $null $tcpClient = $null if ($Connection) { if ($Connection.PSObject.Properties['SslStream']) { $sslStream = $Connection.SslStream } if ($Connection.PSObject.Properties['NetworkStream']) { $networkStream = $Connection.NetworkStream } if ($Connection.PSObject.Properties['TcpClient']) { $tcpClient = $Connection.TcpClient } } 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' 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)] [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 { Write-Debug "[$fn] Failed to dispose TcpClient after connection error: $($_.Exception.GetType().FullName)" } $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' 61 #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, $NegotiatedCipherSuite, $HashAlgorithm, [int]$HashStrength, $KeyExchangeAlgorithm, [int]$KeyExchangeStrength, [bool]$IsMutuallyAuthenticated = $false, [bool]$IsEncrypted = $false, [bool]$IsSigned = $false, $NegotiatedApplicationProtocol, [bool]$ForwardSecrecy = $false, [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 NegotiatedCipherSuite = $NegotiatedCipherSuite HashAlgorithm = $HashAlgorithm HashStrength = $HashStrength KeyExchangeAlgorithm = $KeyExchangeAlgorithm KeyExchangeStrength = $KeyExchangeStrength IsMutuallyAuthenticated = $IsMutuallyAuthenticated IsEncrypted = $IsEncrypted IsSigned = $IsSigned NegotiatedApplicationProtocol = $NegotiatedApplicationProtocol ForwardSecrecy = $ForwardSecrecy 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' 118 #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()] [psobject]$Connection ) $fn = $MyInvocation.MyCommand.Name $sw = [System.Diagnostics.Stopwatch]::StartNew() $sslStream = $null if ($Connection.PSObject.Properties['SslStream']) { $sslStream = $Connection.SslStream } if ($null -eq $sslStream) { throw [System.InvalidOperationException]::new('Connection context does not contain an authenticated SslStream.') } 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' 43 #Region '.\private\Get-TlsHandshakeDetails.ps1' -1 function Get-TlsHandshakeDetails { <# .SYNOPSIS Returns negotiated TLS/certificate validation details for an authenticated SslStream. .OUTPUTS PSCustomObject { NegotiatedProtocol, CipherAlgorithm, CipherStrength, NegotiatedCipherSuite, HashAlgorithm, HashStrength, KeyExchangeAlgorithm, KeyExchangeStrength, IsMutuallyAuthenticated, IsEncrypted, IsSigned, NegotiatedApplicationProtocol, ForwardSecrecy, CertificateValidationPassed, CertificatePolicyErrors, CertificatePolicyErrorFlags, CertificateChainStatus } #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateNotNull()] [psobject]$Connection ) $fn = $MyInvocation.MyCommand.Name $sw = [System.Diagnostics.Stopwatch]::StartNew() $sslStream = $null if ($Connection.PSObject.Properties['SslStream']) { $sslStream = $Connection.SslStream } if ($null -eq $sslStream) { throw [System.InvalidOperationException]::new('Connection context does not contain an authenticated SslStream.') } Write-Verbose "[$fn] Begin (IsAuthenticated=$($sslStream.IsAuthenticated))" try { $validationState = $null if ('TLSleuth.CertificateValidationCallbacksV2' -as [type]) { $validationState = [TLSleuth.CertificateValidationCallbacksV2]::GetState($sslStream) } $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) $negotiatedCipherSuite = $null if ($sslStream.PSObject.Properties['NegotiatedCipherSuite']) { $negotiatedCipherSuite = $sslStream.NegotiatedCipherSuite } $negotiatedApplicationProtocol = $null if ($sslStream.PSObject.Properties['NegotiatedApplicationProtocol']) { $negotiatedApplicationProtocol = $sslStream.NegotiatedApplicationProtocol } $keyExchangeAlgorithm = $sslStream.KeyExchangeAlgorithm $forwardSecrecy = [string]$keyExchangeAlgorithm -match 'ECDHE|DHE' if (-not $forwardSecrecy -and $null -ne $negotiatedCipherSuite) { $forwardSecrecy = [string]$negotiatedCipherSuite -match 'ECDHE|DHE' } Write-Verbose "[$fn] Extracted details (Protocol=$($sslStream.SslProtocol), Cipher=$($sslStream.CipherAlgorithm), Strength=$($sslStream.CipherStrength), ValidationPassed=$validationPassed, PolicyErrors=$policyErrors, ForwardSecrecy=$forwardSecrecy)." [PSCustomObject]@{ NegotiatedProtocol = $sslStream.SslProtocol CipherAlgorithm = $sslStream.CipherAlgorithm CipherStrength = $sslStream.CipherStrength NegotiatedCipherSuite = $negotiatedCipherSuite HashAlgorithm = $sslStream.HashAlgorithm HashStrength = $sslStream.HashStrength KeyExchangeAlgorithm = $keyExchangeAlgorithm KeyExchangeStrength = $sslStream.KeyExchangeStrength IsMutuallyAuthenticated = $sslStream.IsMutuallyAuthenticated IsEncrypted = $sslStream.IsEncrypted IsSigned = $sslStream.IsSigned NegotiatedApplicationProtocol = $negotiatedApplicationProtocol ForwardSecrecy = $forwardSecrecy CertificateValidationPassed = $validationPassed CertificatePolicyErrors = $policyErrors CertificatePolicyErrorFlags = [string[]]$policyErrorFlags CertificateChainStatus = [string[]]$chainStatus } } finally { try { if ('TLSleuth.CertificateValidationCallbacksV2' -as [type]) { [TLSleuth.CertificateValidationCallbacksV2]::Cleanup($sslStream) } } catch { Write-Debug "[$fn] Failed to clean up certificate validation callback state: $($_.Exception.GetType().FullName)" } $sw.Stop() Write-Verbose "[$fn] Complete in $($sw.Elapsed)" } } #EndRegion '.\private\Get-TlsHandshakeDetails.ps1' 110 #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 ConvertFrom-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 = ConvertFrom-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 = ConvertFrom-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 = ConvertFrom-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-TlsTransportNegotiation.ps1' -1 function Invoke-TlsTransportNegotiation { <# .SYNOPSIS Dispatches transport-specific plaintext negotiation before TLS handshake. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('ImplicitTls','SmtpStartTls','ImapStartTls','Pop3StartTls')] [string]$Transport, [Parameter(Mandatory)] [ValidateNotNull()] [psobject]$Connection, [Parameter(Mandatory)] [ValidateNotNull()] [psobject]$Options ) $fn = $MyInvocation.MyCommand.Name $sw = [System.Diagnostics.Stopwatch]::StartNew() $timeoutMs = if ($Options.PSObject.Properties['TimeoutMs']) { [int]$Options.TimeoutMs } else { 10000 } Write-Verbose "[$fn] Begin (Transport=$Transport, TimeoutMs=$timeoutMs)" if (-not $Connection.PSObject.Properties['NetworkStream'] -or $null -eq $Connection.NetworkStream) { throw [System.InvalidOperationException]::new('Transport negotiation requires a connection with a non-null NetworkStream.') } # Declarative transport adapter table keeps public orchestration logic transport-agnostic. $protocolAdapters = @{ ImplicitTls = { Write-Verbose "[$fn] Transport ImplicitTls selected; no plaintext negotiation required." } SmtpStartTls = { param($AdapterConnection, $AdapterOptions) $ehloName = $null if ($AdapterOptions.PSObject.Properties['SmtpEhloName']) { $ehloName = $AdapterOptions.SmtpEhloName } if ([string]::IsNullOrWhiteSpace($ehloName)) { $ehloName = [System.Net.Dns]::GetHostName() if ([string]::IsNullOrWhiteSpace($ehloName)) { $ehloName = 'localhost' } } Invoke-SmtpStartTlsNegotiation ` -NetworkStream $AdapterConnection.NetworkStream ` -EhloName $ehloName ` -TimeoutMs $timeoutMs | Out-Null } ImapStartTls = { param($AdapterConnection) Invoke-ImapStartTlsNegotiation ` -NetworkStream $AdapterConnection.NetworkStream ` -TimeoutMs $timeoutMs | Out-Null } Pop3StartTls = { param($AdapterConnection) Invoke-Pop3StartTlsNegotiation ` -NetworkStream $AdapterConnection.NetworkStream ` -TimeoutMs $timeoutMs | Out-Null } } try { $adapter = $protocolAdapters[$Transport] if ($null -eq $adapter) { throw [System.InvalidOperationException]::new("No transport adapter is configured for '$Transport'.") } & $adapter $Connection $Options } catch { Write-Debug "[$fn] Transport negotiation failed (Transport=$Transport): $($_.Exception.GetType().FullName)" throw } finally { $sw.Stop() Write-Verbose "[$fn] Complete in $($sw.Elapsed)" } } #EndRegion '.\private\Invoke-TlsTransportNegotiation.ps1' 86 #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 { Write-Debug "[$fn] Failed to restore stream read timeout: $($_.Exception.GetType().FullName)" } try { $Stream.WriteTimeout = $originalWriteTimeout } catch { Write-Debug "[$fn] Failed to restore stream write timeout: $($_.Exception.GetType().FullName)" } } $sw.Stop() Write-Verbose "[$fn] Complete in $($sw.Elapsed)" } } #EndRegion '.\private\Invoke-WithStreamTimeout.ps1' 62 #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 System.Net.Security.SslStream #> [CmdletBinding()] [OutputType([System.Net.Security.SslStream])] param( [Parameter(Mandatory)] [ValidateNotNull()] [psobject]$Connection, [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 $handshakeSucceeded = $false $networkStream = $null if ($Connection.PSObject.Properties['NetworkStream']) { $networkStream = $Connection.NetworkStream } if ($null -eq $networkStream) { throw [System.InvalidOperationException]::new('Connection context must include a non-null NetworkStream.') } if (-not ('TLSleuth.CertificateValidationCallbacksV2' -as [type])) { Add-Type -TypeDefinition @" using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Net.Security; using System.Security.Cryptography.X509Certificates; namespace TLSleuth { public sealed class CertificateValidationStateV2 { public SslPolicyErrors PolicyErrors { get; set; } public string[] ChainStatus { get; set; } = new string[0]; } internal sealed class CertificateValidationOptionsV2 { public bool SkipValidation { get; set; } } public static class CertificateValidationCallbacksV2 { private static readonly ConditionalWeakTable<object, CertificateValidationOptionsV2> OptionsBySender = new ConditionalWeakTable<object, CertificateValidationOptionsV2>(); private static readonly ConditionalWeakTable<object, CertificateValidationStateV2> StateBySender = new ConditionalWeakTable<object, CertificateValidationStateV2>(); public static void Register(object sender, bool skipValidation) { if (sender == null) { return; } OptionsBySender.Remove(sender); StateBySender.Remove(sender); OptionsBySender.Add(sender, new CertificateValidationOptionsV2 { SkipValidation = skipValidation }); } public static CertificateValidationStateV2 GetState(object sender) { if (sender == null) { return null; } CertificateValidationStateV2 state; return StateBySender.TryGetValue(sender, out state) ? state : null; } public static void Cleanup(object sender) { if (sender == null) { return; } OptionsBySender.Remove(sender); StateBySender.Remove(sender); } 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()); } } } if (sender != null) { StateBySender.Remove(sender); StateBySender.Add(sender, new CertificateValidationStateV2 { PolicyErrors = sslPolicyErrors, ChainStatus = chainStatuses.ToArray() }); CertificateValidationOptionsV2 options; var skipValidation = OptionsBySender.TryGetValue(sender, out options) && options != null && options.SkipValidation; return skipValidation || sslPolicyErrors == SslPolicyErrors.None; } return sslPolicyErrors == SslPolicyErrors.None; } } } "@ } try { $ssl = [System.Net.Security.SslStream]::new( $networkStream, $false, [System.Net.Security.RemoteCertificateValidationCallback][TLSleuth.CertificateValidationCallbacksV2]::CaptureAndValidate ) if ($Connection.PSObject.Properties['SslStream']) { $Connection.SslStream = $ssl } else { $Connection | Add-Member -NotePropertyName 'SslStream' -NotePropertyValue $ssl } [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") } Write-Verbose "[$fn] Handshake succeeded (Protocol=$($ssl.SslProtocol), Cipher=$($ssl.CipherAlgorithm), Strength=$($ssl.CipherStrength))." $handshakeSucceeded = $true $ssl } catch { Write-Debug "[$fn] Handshake failed for ${TargetHost}: $($_.Exception.GetType().FullName)" try { if ($ssl) { [TLSleuth.CertificateValidationCallbacksV2]::Cleanup($ssl) $ssl.Dispose() } } catch { Write-Debug "[$fn] Failed to dispose SslStream after handshake error: $($_.Exception.GetType().FullName)" } $errorToThrow = $_.Exception if ($errorToThrow -is [System.AggregateException] -and $errorToThrow.InnerException) { throw $errorToThrow.InnerException } throw } finally { try { if (-not $handshakeSucceeded -and $ssl) { [TLSleuth.CertificateValidationCallbacksV2]::Cleanup($ssl) } } catch { Write-Debug "[$fn] Failed to clean up certificate validation callback state: $($_.Exception.GetType().FullName)" } $sw.Stop() Write-Verbose "[$fn] Complete in $($sw.Elapsed)" } } #EndRegion '.\private\Start-TlsHandshake.ps1' 200 #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 } $connection = $null $tlsDetails = $null $certificate = $null try { $connection = Invoke-WithRetry -ScriptBlock { Connect-TcpWithTimeout -Hostname $Hostname -Port $Port -TimeoutMs $timeoutMs } if (-not $connection.PSObject.Properties['SslStream']) { $connection | Add-Member -NotePropertyName 'SslStream' -NotePropertyValue $null } $transportOptions = [PSCustomObject]@{ TimeoutMs = $timeoutMs SmtpEhloName = $SmtpEhloName } Invoke-TlsTransportNegotiation ` -Transport $Transport ` -Connection $connection ` -Options $transportOptions $handshakeStream = Start-TlsHandshake ` -Connection $connection ` -TargetHost $target ` -SslProtocols $sslProtocols ` -TimeoutMs $timeoutMs ` -SkipCertificateValidation:$SkipCertificateValidation $connection.SslStream = $handshakeStream $tlsDetails = Get-TlsHandshakeDetails -Connection $connection $certificate = Get-RemoteCertificate -Connection $connection $validity = Test-TlsCertificateValidity -Certificate $certificate ConvertTo-TlsCertificateResult ` -Hostname $Hostname ` -Port $Port ` -TargetHost $target ` -Certificate $certificate ` -Validity $validity ` -CertificateValidationPassed $tlsDetails.CertificateValidationPassed ` -CertificatePolicyErrors $tlsDetails.CertificatePolicyErrors ` -CertificatePolicyErrorFlags $tlsDetails.CertificatePolicyErrorFlags ` -CertificateChainStatus $tlsDetails.CertificateChainStatus ` -NegotiatedProtocol $tlsDetails.NegotiatedProtocol ` -CipherAlgorithm $tlsDetails.CipherAlgorithm ` -CipherStrength $tlsDetails.CipherStrength ` -NegotiatedCipherSuite $tlsDetails.NegotiatedCipherSuite ` -HashAlgorithm $tlsDetails.HashAlgorithm ` -HashStrength $tlsDetails.HashStrength ` -KeyExchangeAlgorithm $tlsDetails.KeyExchangeAlgorithm ` -KeyExchangeStrength $tlsDetails.KeyExchangeStrength ` -IsMutuallyAuthenticated $tlsDetails.IsMutuallyAuthenticated ` -IsEncrypted $tlsDetails.IsEncrypted ` -IsSigned $tlsDetails.IsSigned ` -NegotiatedApplicationProtocol $tlsDetails.NegotiatedApplicationProtocol ` -ForwardSecrecy $tlsDetails.ForwardSecrecy ` -Elapsed $itemSw.Elapsed } finally { $itemSw.Stop() Close-NetworkResources -Connection $connection } } end { $pipelineSw.Stop() Write-Verbose "[$($MyInvocation.MyCommand.Name)] Complete (Processed=$processed) in $($pipelineSw.Elapsed)" } } #EndRegion '.\public\Get-TLSleuthCertificate.ps1' 130 #Region '.\public\Test-TLSleuthProtocol.ps1' -1 function Test-TLSleuthProtocol { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('Host','DnsName','ComputerName','Target','Name')] [ValidateNotNullOrEmpty()] [string]$Hostname, [Parameter(ValueFromPipelineByPropertyName)] [ValidateRange(1,65535)] [int]$Port = 443, [Parameter(ValueFromPipelineByPropertyName)] [Alias('SNI','ServerName')] [string]$TargetHost, [Parameter(ValueFromPipelineByPropertyName)] [ValidateSet('ImplicitTls','SmtpStartTls','ImapStartTls','Pop3StartTls')] [string]$Transport = 'ImplicitTls', [Parameter(ValueFromPipelineByPropertyName)] [Alias('EhloName','ClientName')] [string]$SmtpEhloName, [ValidateRange(1,600)] [int]$TimeoutSec = 10, [switch]$SkipCertificateValidation = $true ) begin { $fn = $MyInvocation.MyCommand.Name $pipelineSw = [System.Diagnostics.Stopwatch]::StartNew() $processed = 0 $timeoutMs = $TimeoutSec * 1000 $knownProtocols = @('Ssl3','Tls','Tls11','Tls12','Tls13') $enumNames = [System.Enum]::GetNames([System.Security.Authentication.SslProtocols]) $availableProtocols = @( foreach ($name in $knownProtocols) { if ($enumNames -contains $name) { [System.Security.Authentication.SslProtocols]::$name } } ) if (-not $availableProtocols -or $availableProtocols.Count -eq 0) { throw [System.InvalidOperationException]::new('No explicit SslProtocols values are available on this runtime.') } Write-Verbose "[$fn] Begin (Transport=$Transport, TimeoutSec=$TimeoutSec, Protocols=$($availableProtocols -join ','))" } process { $processed++ $target = if ([string]::IsNullOrWhiteSpace($TargetHost)) { $Hostname } else { $TargetHost } foreach ($protocol in $availableProtocols) { $itemSw = [System.Diagnostics.Stopwatch]::StartNew() $connection = $null $tlsDetails = $null $connectionSuccessful = $false $errorMessage = $null try { $connection = Invoke-WithRetry -ScriptBlock { Connect-TcpWithTimeout -Hostname $Hostname -Port $Port -TimeoutMs $timeoutMs } if (-not $connection.PSObject.Properties['SslStream']) { $connection | Add-Member -NotePropertyName 'SslStream' -NotePropertyValue $null } $transportOptions = [PSCustomObject]@{ TimeoutMs = $timeoutMs SmtpEhloName = $SmtpEhloName } Invoke-TlsTransportNegotiation ` -Transport $Transport ` -Connection $connection ` -Options $transportOptions $handshakeStream = Start-TlsHandshake ` -Connection $connection ` -TargetHost $target ` -SslProtocols $protocol ` -TimeoutMs $timeoutMs ` -SkipCertificateValidation:$SkipCertificateValidation $connection.SslStream = $handshakeStream $tlsDetails = Get-TlsHandshakeDetails -Connection $connection $connectionSuccessful = $true } catch { $errorMessage = $_.Exception.Message Write-Debug "[$fn] Protocol $protocol failed for ${Hostname}:$Port - $errorMessage" } finally { $itemSw.Stop() Close-NetworkResources -Connection $connection } [PSCustomObject]@{ PSTypeName = 'TLSleuth.ProtocolTestResult' Hostname = $Hostname Port = $Port TargetHost = $target Transport = $Transport Protocol = $protocol ConnectionSuccessful = $connectionSuccessful ErrorMessage = $errorMessage NegotiatedProtocol = if ($tlsDetails) { $tlsDetails.NegotiatedProtocol } else { $null } CipherAlgorithm = if ($tlsDetails) { $tlsDetails.CipherAlgorithm } else { $null } CipherStrength = if ($tlsDetails) { $tlsDetails.CipherStrength } else { $null } NegotiatedCipherSuite = if ($tlsDetails) { $tlsDetails.NegotiatedCipherSuite } else { $null } HashAlgorithm = if ($tlsDetails) { $tlsDetails.HashAlgorithm } else { $null } HashStrength = if ($tlsDetails) { $tlsDetails.HashStrength } else { $null } KeyExchangeAlgorithm = if ($tlsDetails) { $tlsDetails.KeyExchangeAlgorithm } else { $null } KeyExchangeStrength = if ($tlsDetails) { $tlsDetails.KeyExchangeStrength } else { $null } IsMutuallyAuthenticated = if ($tlsDetails) { $tlsDetails.IsMutuallyAuthenticated } else { $null } IsEncrypted = if ($tlsDetails) { $tlsDetails.IsEncrypted } else { $null } IsSigned = if ($tlsDetails) { $tlsDetails.IsSigned } else { $null } NegotiatedApplicationProtocol = if ($tlsDetails) { $tlsDetails.NegotiatedApplicationProtocol } else { $null } ForwardSecrecy = if ($tlsDetails) { $tlsDetails.ForwardSecrecy } else { $null } CertificateValidationPassed = if ($tlsDetails) { $tlsDetails.CertificateValidationPassed } else { $null } CertificatePolicyErrors = if ($tlsDetails) { $tlsDetails.CertificatePolicyErrors } else { $null } CertificatePolicyErrorFlags = if ($tlsDetails) { $tlsDetails.CertificatePolicyErrorFlags } else { @() } CertificateChainStatus = if ($tlsDetails) { $tlsDetails.CertificateChainStatus } else { @() } ElapsedMs = [int][Math]::Round($itemSw.Elapsed.TotalMilliseconds) } } } end { $pipelineSw.Stop() Write-Verbose "[$fn] Complete (Processed=$processed) in $($pipelineSw.Elapsed)" } } #EndRegion '.\public\Test-TLSleuthProtocol.ps1' 142 |