Start-TlsHandshake is the point where TLSleuth turns an already-open network
connection into an authenticated .NET SslStream.
It sits after TCP connection setup and any transport-specific negotiation, and before certificate extraction and result mapping:
connect -> negotiate transport -> Start-TlsHandshake -> inspect TLS -> map result
The helper looks small from the outside. It accepts a connection context,
target host, protocol selection, timeout, and certificate validation behavior.
Internally, it has one important responsibility beyond “start TLS”: it wires up
TLSleuth.CertificateValidationCallbacksV2, a small C# type that lets TLSleuth
capture certificate validation diagnostics during the handshake.
That callback type is what makes these output fields possible:
CertificateValidationPassedCertificatePolicyErrorsCertificatePolicyErrorFlagsCertificateChainStatus
Where Start-TlsHandshake Fits
Both public commands call Start-TlsHandshake:
Get-TLSleuthCertificateTest-TLSleuthProtocol
By the time they call it, TLSleuth has already built an operation context,
opened a TCP connection, and run any required transport negotiation such as
SMTP STARTTLS, POP3 STLS, or RDP security negotiation.
The simplified flow is:
flowchart TD
Command["Public command"]
Context["New-TlsOperationContext"]
Connect["New-TlsConnectionContext"]
Transport["Invoke-TlsTransportNegotiation"]
Handshake["Start-TlsHandshake"]
Callback["TLSleuth.CertificateValidationCallbacksV2"]
Details["Get-TlsHandshakeDetails"]
Certificate["Get-RemoteCertificate"]
Result["TLSleuth result object"]
Cleanup["Close-NetworkResources"]
Command --> Context
Command --> Connect
Command --> Transport
Command --> Handshake
Handshake --> Callback
Command --> Details
Command --> Certificate
Details --> Callback
Command --> Result
Command --> Cleanup
Start-TlsHandshake is deliberately transport-agnostic. It does not care
whether the stream came from HTTPS, SMTP STARTTLS, IMAP STARTTLS, POP3 STLS, or
RDP. Its input is simply a connection object with a usable NetworkStream.
The Helper Contract
The helper signature is:
Start-TlsHandshake `
-Connection <psobject> `
-TargetHost <string> `
-SslProtocols <SslProtocols> `
-TimeoutMs <int> `
[-SkipCertificateValidation]
Connection must include a non-null NetworkStream. On success,
Start-TlsHandshake creates an SslStream, stores it back on
Connection.SslStream, and returns the authenticated stream.
TargetHost is passed into AuthenticateAsClientAsync. It is the name used by
the TLS stack for SNI and certificate name validation.
SslProtocols controls which TLS protocol value or values the handshake may
use.
TimeoutMs bounds the async handshake wait.
SkipCertificateValidation controls whether the validation callback allows the
handshake to continue when the runtime reports certificate validation errors.
There is an important distinction here:
Start-TlsHandshakedefaultsSkipCertificateValidationto false when called directly.- The public commands currently default their own
-SkipCertificateValidationswitch to true for certificate inspection workflows, then pass that value intoStart-TlsHandshake.
That lets TLSleuth retrieve and report certificates that are expired, self-signed, incomplete, or name-mismatched, while still preserving the validation diagnostics.
Why TLSleuth Needs A Validation Callback
SslStream can validate the remote certificate during
AuthenticateAsClientAsync. The validation callback receives useful runtime
facts:
- the certificate
- the certificate chain
- the
SslPolicyErrorsflags
The callback also has to return a boolean. That return value decides whether the handshake should continue.
TLSleuth needs both behaviors:
- It needs the option to continue the handshake so it can inspect certificates that would otherwise be rejected.
- It needs to remember what the TLS stack reported so the final result object can show validation status instead of hiding it.
That is the reason for TLSleuth.CertificateValidationCallbacksV2.
The Dynamically Added C# Type
PowerShell can create an SslStream and pass a validation delegate directly,
but TLSleuth uses Add-Type to define a small C# helper type:
if (-not ('TLSleuth.CertificateValidationCallbacksV2' -as [type])) {
Add-Type -TypeDefinition @"
...
"@
}
The guard matters because a .NET type cannot be redefined with the same name in the same PowerShell session. Once the type exists, later calls reuse it.
The C# code defines three types:
TLSleuth.CertificateValidationStateV2
TLSleuth.CertificateValidationOptionsV2
TLSleuth.CertificateValidationCallbacksV2
CertificateValidationStateV2 stores the validation facts TLSleuth wants to
report later:
public sealed class CertificateValidationStateV2
{
public SslPolicyErrors PolicyErrors { get; set; }
public string[] ChainStatus { get; set; } = new string[0];
}
CertificateValidationOptionsV2 stores per-stream options:
internal sealed class CertificateValidationOptionsV2
{
public bool SkipValidation { get; set; }
}
CertificateValidationCallbacksV2 owns the static callback method and the
per-stream state tables.
Why ConditionalWeakTable Is Used
The callback is static, but each TLS handshake has its own SslStream and its
own validation behavior. TLSleuth needs to associate state with a specific
stream without using global variables that can collide between connections.
The type uses two ConditionalWeakTable<object, ...> instances:
private static readonly ConditionalWeakTable<object, CertificateValidationOptionsV2> OptionsBySender;
private static readonly ConditionalWeakTable<object, CertificateValidationStateV2> StateBySender;
Those tables are keyed by the callback sender, which is the SslStream
instance passed to the validation callback.
The tables serve different purposes:
OptionsBySenderstores whether this stream should skip validation failure.StateBySenderstores what validation errors and chain status were observed.
ConditionalWeakTable is useful here because the key is weakly held. When the
SslStream is no longer reachable, the associated table entries can be
collected. TLSleuth still performs explicit cleanup, but the table shape avoids
turning stream-specific state into long-lived static state.
Registering The Stream
After creating the SslStream, Start-TlsHandshake registers the stream with
the callback type:
[TLSleuth.CertificateValidationCallbacksV2]::Register(
$ssl,
[bool]$SkipCertificateValidation
)
Register removes any stale state for that sender, then records the current
validation option:
OptionsBySender.Remove(sender);
StateBySender.Remove(sender);
OptionsBySender.Add(sender, new CertificateValidationOptionsV2 {
SkipValidation = skipValidation
});
That reset is important. It means a stream starts with clean callback state before the handshake begins.
Capturing And Deciding Validation
The SslStream is created with this callback:
[System.Net.Security.RemoteCertificateValidationCallback]
[TLSleuth.CertificateValidationCallbacksV2]::CaptureAndValidate
During the handshake, .NET calls CaptureAndValidate.
The callback builds a list of non-success chain status values:
if (chain != null && chain.ChainStatus != null)
{
foreach (var status in chain.ChainStatus)
{
if (status.Status != X509ChainStatusFlags.NoError)
{
chainStatuses.Add(status.Status.ToString());
}
}
}
Then it stores the policy errors and chain status for the current sender:
StateBySender.Remove(sender);
StateBySender.Add(sender, new CertificateValidationStateV2
{
PolicyErrors = sslPolicyErrors,
ChainStatus = chainStatuses.ToArray()
});
Finally, it decides whether the handshake can continue:
return skipValidation || sslPolicyErrors == SslPolicyErrors.None;
That line is the behavioral core:
- if validation is clean, allow the handshake
- if validation has errors and skip validation is enabled, allow the handshake
- if validation has errors and skip validation is not enabled, reject the handshake
This gives TLSleuth its inspection behavior without losing validation context.
Starting The Handshake
Once the callback is registered, the helper starts the client handshake:
$task = $ssl.AuthenticateAsClientAsync(
$TargetHost,
$null,
$SslProtocols,
$false
)
Then it waits for the configured timeout:
if (-not $task.Wait($TimeoutMs)) {
throw (New-TlsTimeoutException `
-Operation 'TLS handshake' `
-TimeoutMs $TimeoutMs `
-TargetHost $TargetHost)
}
If the wait times out, TLSleuth throws a consistent timeout exception. If the
handshake fails for another reason, the catch block unwraps common PowerShell
and task wrapper exceptions using Resolve-TlsException, adds TLSleuth error
context, and rethrows the actionable exception.
On success, the function returns the authenticated SslStream.
Reading The Captured Validation State
Start-TlsHandshake captures validation state, but it does not map that state
into TLSleuth’s result objects directly.
That work happens in Get-TlsHandshakeDetails:
$validationState = [TLSleuth.CertificateValidationCallbacksV2]::GetState($sslStream)
If state exists, Get-TlsHandshakeDetails uses it to populate:
CertificateValidationPassed = $validationPassed
CertificatePolicyErrors = $policyErrors
CertificatePolicyErrorFlags = [string[]]$policyErrorFlags
CertificateChainStatus = [string[]]$chainStatus
It also expands SslPolicyErrors into stable string flags:
RemoteCertificateNotAvailableRemoteCertificateNameMismatchRemoteCertificateChainErrors
That separation keeps responsibilities clear:
Start-TlsHandshakecreates and authenticates the TLS stream.TLSleuth.CertificateValidationCallbacksV2captures validation facts during authentication.Get-TlsHandshakeDetailsreads those facts and maps them into TLSleuth’s structured output model.
Cleanup Behavior
There are two cleanup paths.
If the handshake fails, Start-TlsHandshake cleans up callback state and
disposes the SslStream:
[TLSleuth.CertificateValidationCallbacksV2]::Cleanup($ssl)
Close-TlsResource -Resource $ssl -ResourceName 'SslStream' -OwnerName $fn
If the handshake succeeds, Start-TlsHandshake intentionally leaves the
callback state available. Get-TlsHandshakeDetails still needs to read it.
After reading it, Get-TlsHandshakeDetails calls:
[TLSleuth.CertificateValidationCallbacksV2]::Cleanup($sslStream)
That means the success path is:
Start-TlsHandshake captures state -> Get-TlsHandshakeDetails reads state -> cleanup
The failure path is:
Start-TlsHandshake captures or creates state -> failure -> cleanup immediately
Why The Callback Does Not Store The Certificate
The callback receives the certificate, but TLSleuth does not store it in
CertificateValidationStateV2.
Certificate extraction is handled separately by Get-RemoteCertificate, which
reads the remote certificate from the authenticated SslStream.
That keeps the callback focused on validation diagnostics, not certificate ownership. It also keeps result mapping in the existing certificate extraction path instead of hiding certificate state inside the validation callback.
Failure Handling
Start-TlsHandshake adds context to failures without changing the exception
type unnecessarily.
For example, if the connection object does not include a NetworkStream, it
throws an InvalidOperationException and adds:
TLSleuth.Stage = TlsHandshake
TLSleuth.Operation = TLS handshake
TLSleuth.TargetHost = <target host>
For wrapped async failures, Resolve-TlsException unwraps common wrapper types
such as MethodInvocationException and single-inner AggregateException
instances. That is why protocol testing can report the underlying TLS error
instead of a less helpful message like an exception from Task.Wait.
What The Tests Cover
The loopback integration test for Start-TlsHandshake creates a self-signed
certificate, runs a local TLS server, starts a client handshake, and then reads
the handshake details.
The important validation assertions are:
$tlsDetails.CertificateValidationPassed | Should -BeFalse
$tlsDetails.CertificatePolicyErrorFlags | Should -Contain 'RemoteCertificateChainErrors'
That test proves the core behavior:
- the handshake can continue when validation is skipped
- TLSleuth still captures and reports the validation problem
There is also unit coverage for TLS handshake error context, which verifies
that validation failures in the helper are categorized as TlsHandshake
failures with the expected operation metadata.
Operational Caveats
TLSleuth uses the .NET TLS stack through SslStream. That means certificate
validation and available TLS protocol behavior depend on:
- the .NET runtime
- the operating system TLS policy
- the local certificate trust store
- the server’s certificate and chain presentation
SkipCertificateValidation should not be read as “the certificate is valid.”
It means TLSleuth may allow the handshake to continue so it can inspect and
report the certificate. The result fields still tell you whether validation
passed and which policy errors were observed.
If you need strict validation behavior from a direct helper call, do not pass
-SkipCertificateValidation.
Summary
Start-TlsHandshake is more than a thin wrapper around
SslStream.AuthenticateAsClientAsync.
It:
- validates that the connection context has a network stream
- creates and stores the
SslStream - registers per-stream certificate validation behavior
- starts the async client handshake with a bounded timeout
- preserves validation diagnostics through
TLSleuth.CertificateValidationCallbacksV2 - cleans up callback state on failure
- leaves successful validation state available for
Get-TlsHandshakeDetails - normalizes handshake errors with TLSleuth operation context
TLSleuth.CertificateValidationCallbacksV2 is the bridge between the .NET TLS
validation callback and TLSleuth’s script-friendly output. It lets TLSleuth
inspect imperfect certificates while still showing exactly what the TLS stack
reported during validation.