Skip to the content.

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:

Where Start-TlsHandshake Fits

Both public commands call Start-TlsHandshake:

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:

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 callback also has to return a boolean. That return value decides whether the handshake should continue.

TLSleuth needs both behaviors:

  1. It needs the option to continue the handshake so it can inspect certificates that would otherwise be rejected.
  2. 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:

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:

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:

That separation keeps responsibilities clear:

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:

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:

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:

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.