Skip to content

API Proposal: Public TLS Handles Bound to a Socket — SafeTlsContextHandle, SafeTlsHandle #127928

@DeagleGross

Description

@DeagleGross

TLDR - show me the code

Here is the vibe-coded implementation
Note: this is only the rough implementation, it will be redone carefully once API is approved

Background and motivation

SslStream is the only way to do TLS on .NET today. On Linux it sits on top of OpenSSL via memory BIOs (bio_s_mem). That architecture forces two extra memcpy operations on every TLS read and every TLS write:

Read path  : kernel → managed buffer → BIO_write → OpenSSL → SSL_read out → managed buffer
Write path : managed → SSL_write → OpenSSL → BIO_read → managed → kernel

The alternative is to bind the TLS connection directly to a socket file descriptor, so the TLS provider recv/sends into the kernel itself with no application-side copy. On OpenSSL this is the SSL_set_fd model.

Today there is no public managed surface for this. The OpenSSL P/Invoke layer (Interop.Ssl, Interop.SslCtx) is internal to System.Net.Security. Every server author who wants the direct-fd model has to re-declare the entire OpenSSL P/Invoke surface in their own repo.

This proposal adds two public SafeHandle types in System.Net.Security that represent a TLS configuration context and a TLS connection bound to a socket. Operations and negotiated-info getters live as instance methods/properties on the handles. Configuration uses the existing SslServerAuthenticationOptions / SslClientAuthenticationOptions types.

This proposal does not introduce any state machine, polling loop, async wrapper, or per-connection orchestration.

Naming

  • All new types use Tls prefix (not Ssl) — consistent with modern .NET naming (e.g., TlsCipherSuite). Avoids confusion with the existing internal Microsoft.Win32.SafeHandles.SafeSslHandle.
  • Names are provider-opaque: SafeTlsContextHandle and SafeTlsHandle describe what the types are, not how they are implemented underneath. This mirrors SslStream itself.
  • New types live in System.Net.Security (the feature namespace, where SslStream lives).
  • Initial implementation is OpenSSL-on-Linux, gated by [UnsupportedOSPlatform].Other providers may be supported later.

What each handle represents

SafeTlsContextHandle — long-lived TLS configuration context

The TLS configuration object: certificate(s), private key, protocol version range, ALPN protocol list, session cache parameters. Create one per listener, share across all connections that listener accepts. Thread-safe. Released on Dispose.

Internally wraps OpenSSL's SSL_CTX* (via SSL_CTX_new / SSL_CTX_free). Configuration is applied from SslServerAuthenticationOptions or SslClientAuthenticationOptions during factory construction — no individual setup methods are exposed.

SafeTlsHandle — per-connection TLS session bound to a socket

The per-connection TLS state, bound to a socket file descriptor (no memory BIOs). Owns the handshake state, the negotiated session info (protocol version, cipher suite, ALPN, peer cert, SNI), and a back-reference to its SafeTlsContextHandle. Released on Dispose.

Internally wraps OpenSSL's SSL* (via SSL_newSSL_set_fdSSL_free).

Note on SSL_set_fd. This is the one piece of native plumbing that doesn't exist in dotnet/runtime today — because SslStream always uses memory BIOs (SSL_set_bio). A new internal Interop.Ssl.SslSetFd P/Invoke is needed (one-line addition). Every other native call is reused as-is.

The factory takes a SafeSocketHandle and DangerousAddRefs it for the lifetime of the handle, so the fd cannot be invalidated under the TLS provider.

Goals

  • Public SafeHandle-typed wrappers for the TLS context and TLS connection, bound to a socket fd (no memory BIOs).
  • Small set of operations to drive a non-blocking TLS connection end-to-end.
  • Configuration via existing SslServerAuthenticationOptions / SslClientAuthenticationOptions — no new configuration API to learn.
  • Reuse existing exception types (AuthenticationException, IOException) — no new exception types.
  • Guardrails: Read/Write before handshake completion throws InvalidOperationException.

API Proposal

namespace System.Net.Security;

/// <summary>
/// Status returned from non-blocking TLS operations on <see cref="SafeTlsHandle"/>.
/// </summary>
/// <remarks>
/// Maps to OpenSSL's <c>SSL_get_error()</c> classification in provider-opaque terms.
/// Schannel's <c>SEC_I_CONTINUE_NEEDED</c> / <c>SEC_E_INCOMPLETE_MESSAGE</c> map
/// onto the same enum values if a Windows backing is added later.
/// </remarks>
public enum TlsOperationStatus
{
    /// <summary>
    /// Operation completed successfully. For <see cref="SafeTlsHandle.Read"/>:
    /// <c>bytesRead</c> bytes were produced, or <c>bytesRead == 0</c> means
    /// the peer sent <c>close_notify</c> (clean shutdown).
    /// </summary>
    Complete = 0,

    /// <summary>
    /// The TLS provider needs to read from the underlying socket before it can
    /// make progress. Wait for socket-readable, then call the same method again.
    /// </summary>
    WantRead = 1,

    /// <summary>
    /// The TLS provider needs to write to the underlying socket before it can
    /// make progress. Wait for socket-writable, then call the same method again.
    /// </summary>
    WantWrite = 2,

    /// <summary>
    /// The underlying transport is gone (RST, unexpected EOF before <c>close_notify</c>).
    /// The caller should dispose the <see cref="SafeTlsHandle"/>.
    /// </summary>
    Closed = 3,
}
namespace System.Net.Security;

/// <summary>
/// A long-lived TLS configuration context. Thread-safe; create once per listener,
/// share across many <see cref="SafeTlsHandle"/> instances.
/// </summary>
/// <remarks>
/// Internally wraps OpenSSL's <c>SSL_CTX*</c>. Configuration is applied from
/// <see cref="SslServerAuthenticationOptions"/> or <see cref="SslClientAuthenticationOptions"/>
/// during factory construction.
/// </remarks>
[UnsupportedOSPlatform("windows")]
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
public sealed class SafeTlsContextHandle : SafeHandle
{
    public override bool IsInvalid { get; }
    protected override bool ReleaseHandle();

    /// <summary>
    /// Creates a server-side TLS context configured from the given options.
    /// </summary>
    /// <param name="options">
    /// Server authentication options. Properties mapped to native configuration:
    /// <see cref="SslServerAuthenticationOptions.ServerCertificate"/> or
    /// <see cref="SslServerAuthenticationOptions.ServerCertificateContext"/> → certificate/key,
    /// <see cref="SslServerAuthenticationOptions.EnabledSslProtocols"/> → protocol range,
    /// <see cref="SslServerAuthenticationOptions.ApplicationProtocols"/> → ALPN,
    /// <see cref="SslServerAuthenticationOptions.AllowRenegotiation"/> → renegotiation policy,
    /// <see cref="SslServerAuthenticationOptions.AllowTlsResume"/> → session cache,
    /// <see cref="SslServerAuthenticationOptions.CipherSuitesPolicy"/> → cipher suites.
    /// </param>
    /// <exception cref="AuthenticationException">
    /// Certificate or key configuration failed (e.g., private key mismatch).
    /// </exception>
    /// <exception cref="PlatformNotSupportedException">
    /// The current platform does not support direct-fd TLS.
    /// </exception>
    public static SafeTlsContextHandle Create(SslServerAuthenticationOptions options);

    /// <summary>
    /// Creates a client-side TLS context configured from the given options.
    /// </summary>
    /// <exception cref="PlatformNotSupportedException">
    /// The current platform does not support direct-fd TLS.
    /// </exception>
    public static SafeTlsContextHandle Create(SslClientAuthenticationOptions options);

    /// <summary>
    /// Sets the TLS session cache size. Controls how many resumable sessions
    /// are kept in memory. Only meaningful when
    /// <see cref="SslServerAuthenticationOptions.AllowTlsResume"/> is <see langword="true"/>.
    /// </summary>
    /// <remarks>
    /// This is not available on <see cref="SslServerAuthenticationOptions"/> today.
    /// It maps to OpenSSL's <c>SSL_CTX_sess_set_cache_size</c>.
    /// </remarks>
    public void SetSessionCacheSize(int size);

    /// <summary>
    /// Sets the TLS session timeout. Sessions older than this are not resumed.
    /// </summary>
    /// <remarks>
    /// This is not available on <see cref="SslServerAuthenticationOptions"/> today.
    /// It maps to OpenSSL's <c>SSL_CTX_set_timeout</c>.
    /// </remarks>
    public void SetSessionTimeout(TimeSpan timeout);
}
namespace System.Net.Security;

/// <summary>
/// A per-connection TLS session bound to a socket file descriptor.
/// All operations are non-blocking — the underlying socket must be non-blocking.
/// </summary>
/// <remarks>
/// <para>
/// Internally wraps OpenSSL's <c>SSL*</c>, bound to the socket via
/// <c>SSL_set_fd</c> (no memory BIOs). The <see cref="SafeSocketHandle"/> is
/// ref-counted via <c>DangerousAddRef</c> for the lifetime of this handle.
/// </para>
/// <para>
/// <see cref="Read"/> and <see cref="Write"/> throw
/// <see cref="InvalidOperationException"/> if the handshake has not completed.
/// The caller must drive <see cref="Handshake"/> to completion first.
/// </para>
/// </remarks>
[UnsupportedOSPlatform("windows")]
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
public sealed class SafeTlsHandle : SafeHandle
{
    public override bool IsInvalid { get; }
    protected override bool ReleaseHandle();

    /// <summary>
    /// Creates a TLS connection bound to a socket.
    /// </summary>
    /// <param name="context">The TLS configuration context.</param>
    /// <param name="socket">
    /// The socket to bind. Must be non-blocking. The handle is ref-counted
    /// for the lifetime of this <see cref="SafeTlsHandle"/>.
    /// </param>
    /// <param name="isServer">
    /// <see langword="true"/> for server-side (accept state);
    /// <see langword="false"/> for client-side (connect state).
    /// </param>
    /// <exception cref="IOException">
    /// The native <c>SSL_new</c> or <c>SSL_set_fd</c> call failed.
    /// </exception>
    public static SafeTlsHandle Create(
        SafeTlsContextHandle context,
        SafeSocketHandle socket,
        bool isServer);

    // ── Pre-handshake configuration ───────────────────────────────────────

    /// <summary>
    /// Sets the target host name for SNI (client-side) or for the server to
    /// log/match against.
    /// </summary>
    public void SetTargetHostName(string targetHost);

    /// <summary>
    /// Enables quiet shutdown — <see cref="Shutdown"/> will not wait for the
    /// peer's <c>close_notify</c> before returning <see cref="TlsOperationStatus.Complete"/>.
    /// </summary>
    public void SetQuietShutdown(bool enabled);

    // ── Non-blocking operations ───────────────────────────────────────────

    /// <summary>
    /// Drives the TLS handshake forward. Call repeatedly, observing the
    /// returned status and waiting for socket readiness between calls.
    /// </summary>
    /// <returns>
    /// <see cref="TlsOperationStatus.Complete"/> when the handshake finishes.
    /// <see cref="TlsOperationStatus.WantRead"/> or
    /// <see cref="TlsOperationStatus.WantWrite"/> when the socket isn't ready.
    /// <see cref="TlsOperationStatus.Closed"/> if the transport was lost.
    /// </returns>
    /// <exception cref="AuthenticationException">
    /// A TLS protocol error, certificate verification failure, or other
    /// unrecoverable handshake error occurred.
    /// </exception>
    public TlsOperationStatus Handshake();

    /// <summary>
    /// Reads decrypted data from the TLS connection.
    /// </summary>
    /// <exception cref="InvalidOperationException">
    /// The TLS handshake has not completed.
    /// </exception>
    /// <exception cref="IOException">
    /// An unrecoverable I/O error occurred during decryption.
    /// </exception>
    public TlsOperationStatus Read(Span<byte> buffer, out int bytesRead);

    /// <summary>
    /// Writes data to the TLS connection (encrypts and sends).
    /// </summary>
    /// <exception cref="InvalidOperationException">
    /// The TLS handshake has not completed.
    /// </exception>
    /// <exception cref="IOException">
    /// An unrecoverable I/O error occurred during encryption.
    /// </exception>
    public TlsOperationStatus Write(ReadOnlySpan<byte> buffer, out int bytesWritten);

    /// <summary>
    /// Initiates a TLS shutdown (<c>close_notify</c>).
    /// </summary>
    /// <exception cref="IOException">
    /// An unrecoverable I/O error occurred during shutdown.
    /// </exception>
    public TlsOperationStatus Shutdown();

    // ── Negotiated info (valid once Handshake returned Complete) ──────────

    /// <summary>Gets the negotiated TLS protocol version.</summary>
    public SslProtocols NegotiatedProtocol { get; }

    /// <summary>Gets the negotiated cipher suite.</summary>
    public TlsCipherSuite NegotiatedCipherSuite { get; }

    /// <summary>Gets the negotiated ALPN protocol.</summary>
    public SslApplicationProtocol NegotiatedApplicationProtocol { get; }

    /// <summary>Gets the target host name (SNI) if set.</summary>
    public string? TargetHostName { get; }

    /// <summary>
    /// Gets whether this connection resumed a previous TLS session
    /// (session ticket / session ID reuse).
    /// </summary>
    public bool SessionResumed { get; }

    /// <summary>Gets the peer's certificate, if one was presented.</summary>
    public X509Certificate2? GetRemoteCertificate();
}

Error model

Scenario What happens
WantRead / WantWrite Returned as TlsOperationStatus. Never throws. Go back to poll, retry.
Clean EOF (close_notify) Read returns Complete with bytesRead == 0.
Transport gone (RST, unexpected EOF) Returns TlsOperationStatus.Closed.
Handshake protocol error, cert failure Handshake throws AuthenticationException — consistent with SslStream.
Post-handshake I/O error Read/Write throw IOException — consistent with stream I/O conventions.
Read/Write before handshake Throws InvalidOperationException.
Renegotiation (TLS 1.2) Transparent — OpenSSL handles it internally. Read/Write return WantRead/WantWrite, consumer retries. No API change needed. Controlled by SslServerAuthenticationOptions.AllowRenegotiation.
Key update (TLS 1.3) Fully transparent — OpenSSL handles it internally with no visible status change.

Reusing AuthenticationException for handshake failures and IOException for post-handshake errors avoids introducing any new exception types while being consistent with how SslStream behaves today.

API Usage

using System.Net.Security;

// ── One-time per listener ─────────────────────────────────────────────────
var ctx = SafeTlsContextHandle.Create(new SslServerAuthenticationOptions
{
    ServerCertificateContext = SslStreamCertificateContext.Create(myCert, additionalCertificates: null),
    EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
    ApplicationProtocols = [SslApplicationProtocol.Http2, SslApplicationProtocol.Http11],
    AllowTlsResume = true,
    AllowRenegotiation = false,
});
ctx.SetSessionCacheSize(20_480);
ctx.SetSessionTimeout(TimeSpan.FromHours(1));

// ── Per accepted connection ───────────────────────────────────────────────
var tls = SafeTlsHandle.Create(ctx, clientSocketHandle, isServer: true);

// Drive handshake from the consumer's own poll loop:
while (true)
{
    var status = tls.Handshake();
    if (status == TlsOperationStatus.Complete) break;
    if (status == TlsOperationStatus.Closed) { tls.Dispose(); return; }
    WaitForReadiness(clientSocketHandle, status); // consumer's SafePollHandle
}

// Steady-state read (same pattern for write):
Span<byte> buf = stackalloc byte[16 * 1024];
var rs = tls.Read(buf, out int n);
if (rs == TlsOperationStatus.Complete && n > 0)
{
    ProcessData(buf[..n]);
}

Design details

Configuration via SslServerAuthenticationOptions (not individual methods)

The original prototype exposed individual configuration methods on SafeTlsContextHandle: UseCertificate, SetProtocols, SetApplicationProtocols, etc. I replaced this with a factory that accepts SslServerAuthenticationOptions / SslClientAuthenticationOptions:

// Before (individual methods):
var ctx = SafeTlsContextHandle.CreateServer();
ctx.UseCertificateContext(certCtx);
ctx.SetProtocols(SslProtocols.Tls12 | SslProtocols.Tls13);
ctx.SetApplicationProtocols(alpnList);

// After (options-based factory):
var ctx = SafeTlsContextHandle.Create(new SslServerAuthenticationOptions
{
    ServerCertificateContext = certCtx,
    EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
    ApplicationProtocols = alpnList,
});
  • Zero new configuration API — users already know these options from SslStream.AuthenticateAsServerAsync.
  • All properties mapped at once — no risk of forgetting to call a setup method.
  • Immutable after construction — the context can't be mutated after connections start using it.

Two configuration knobs are not on SslServerAuthenticationOptions today: SessionCacheSize and SessionTimeout. These remain as instance methods on SafeTlsContextHandle until they can be added to the options type.

Guardrails — handshake state tracking

SafeTlsHandle tracks whether the handshake has completed. Read and Write throw InvalidOperationException if called before Handshake returns TlsOperationStatus.Complete:

public TlsOperationStatus Read(Span<byte> buffer, out int bytesRead)
{
    if (!_handshakeCompleted)
        throw new InvalidOperationException("TLS handshake has not been completed.");
    // ... SSL_read ...
}

This prevents subtle bugs where SSL_read called before handshake would return confusing errors. The overhead is a single bool check — negligible even on the hot path.

Renegotiation and TLS 1.3 key update

TLS 1.2 renegotiation: If the peer initiates renegotiation during Read/Write, OpenSSL handles it transparently. SSL_read/SSL_write return SSL_ERROR_WANT_READ or SSL_ERROR_WANT_WRITE while the renegotiation handshake progresses. The consumer's existing retry loop (go back to poll, retry the same call) handles this with no API changes. Whether renegotiation is allowed is controlled by SslServerAuthenticationOptions.AllowRenegotiation, which maps to SSL_OP_NO_RENEGOTIATION internally.

TLS 1.3 key update: TLS 1.3 replaced renegotiation with KeyUpdate messages. OpenSSL handles these entirely internally — the consumer never sees them. No API consideration needed.

TLS 1.3 Post-Handshake Authentication (PHA): If ClientCertificateRequired is set and the negotiated protocol is TLS 1.3, the runtime's existing CryptoNative_SslRenegotiate function handles PHA via SSL_verify_client_post_handshake + SSL_do_handshake. This could be exposed as a future method if needed.

Cross-provider comparison

BoringSSL / LibreSSL: Both are API-compatible with OpenSSL for the calls we use (SSL_CTX_new, SSL_new, SSL_set_fd, SSL_do_handshake, SSL_read, SSL_write, SSL_shutdown). No special conditionals are needed. The runtime's native layer already uses OpenSSL version-based conditionals (OPENSSL_VERSION_*) that cover BoringSSL/LibreSSL transparently.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions