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 |