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_new → SSL_set_fd → SSL_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.
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
SslStreamis 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 extramemcpyoperations on every TLS read and every TLS write: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 theSSL_set_fdmodel.Today there is no public managed surface for this. The OpenSSL P/Invoke layer (
Interop.Ssl,Interop.SslCtx) isinternaltoSystem.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
SafeHandletypes inSystem.Net.Securitythat 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 existingSslServerAuthenticationOptions/SslClientAuthenticationOptionstypes.This proposal does not introduce any state machine, polling loop, async wrapper, or per-connection orchestration.
Naming
Tlsprefix (notSsl) — consistent with modern .NET naming (e.g.,TlsCipherSuite). Avoids confusion with the existing internalMicrosoft.Win32.SafeHandles.SafeSslHandle.SafeTlsContextHandleandSafeTlsHandledescribe what the types are, not how they are implemented underneath. This mirrorsSslStreamitself.System.Net.Security(the feature namespace, whereSslStreamlives).[UnsupportedOSPlatform].Other providers may be supported later.What each handle represents
SafeTlsContextHandle— long-lived TLS configuration contextThe 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*(viaSSL_CTX_new/SSL_CTX_free). Configuration is applied fromSslServerAuthenticationOptionsorSslClientAuthenticationOptionsduring factory construction — no individual setup methods are exposed.SafeTlsHandle— per-connection TLS session bound to a socketThe 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 onDispose.Internally wraps OpenSSL's
SSL*(viaSSL_new→SSL_set_fd→SSL_free).The factory takes a
SafeSocketHandleandDangerousAddRefs it for the lifetime of the handle, so the fd cannot be invalidated under the TLS provider.Goals
SafeHandle-typed wrappers for the TLS context and TLS connection, bound to a socket fd (no memory BIOs).SslServerAuthenticationOptions/SslClientAuthenticationOptions— no new configuration API to learn.AuthenticationException,IOException) — no new exception types.Read/Writebefore handshake completion throwsInvalidOperationException.API Proposal
Error model
WantRead/WantWriteTlsOperationStatus. Never throws. Go back to poll, retry.close_notify)ReadreturnsCompletewithbytesRead == 0.TlsOperationStatus.Closed.HandshakethrowsAuthenticationException— consistent withSslStream.Read/WritethrowIOException— consistent with stream I/O conventions.Read/Writebefore handshakeInvalidOperationException.Read/WritereturnWantRead/WantWrite, consumer retries. No API change needed. Controlled bySslServerAuthenticationOptions.AllowRenegotiation.Reusing
AuthenticationExceptionfor handshake failures andIOExceptionfor post-handshake errors avoids introducing any new exception types while being consistent with howSslStreambehaves today.API Usage
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 acceptsSslServerAuthenticationOptions/SslClientAuthenticationOptions:SslStream.AuthenticateAsServerAsync.Two configuration knobs are not on
SslServerAuthenticationOptionstoday:SessionCacheSizeandSessionTimeout. These remain as instance methods onSafeTlsContextHandleuntil they can be added to the options type.Guardrails — handshake state tracking
SafeTlsHandletracks whether the handshake has completed.ReadandWritethrowInvalidOperationExceptionif called beforeHandshakereturnsTlsOperationStatus.Complete:This prevents subtle bugs where
SSL_readcalled before handshake would return confusing errors. The overhead is a singleboolcheck — 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_writereturnSSL_ERROR_WANT_READorSSL_ERROR_WANT_WRITEwhile 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 bySslServerAuthenticationOptions.AllowRenegotiation, which maps toSSL_OP_NO_RENEGOTIATIONinternally.TLS 1.3 key update: TLS 1.3 replaced renegotiation with
KeyUpdatemessages. OpenSSL handles these entirely internally — the consumer never sees them. No API consideration needed.TLS 1.3 Post-Handshake Authentication (PHA): If
ClientCertificateRequiredis set and the negotiated protocol is TLS 1.3, the runtime's existingCryptoNative_SslRenegotiatefunction handles PHA viaSSL_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.