Background and motivation
Right now, SslStream is the only way how one can do TLS in .NET. It offers Stream interface and it is real legacy since .NET Framework, It works fine for majority cases but there are also cases when it does not fit that well. Examples may be inside of asp.net server where the consumer does not use Stream but it is forced to consume that interface. Another example may be various databases or custom protocol where all you want is to pump data from socket (sometimes synchronously) and decrypt and consume your own framing.
The SslStream also inherited various craft over the years and we need to drag some forward for compatibility reasons.
This is mostly minimalists API driven by performance in some niche scenarios. While they may not be common, but the benefits still may be interesting. It is very similar to #127928 but there are some significant differences.
- this proposal aims to be supported on all platforms. While there may be constructs that may see perf benefits on some OSes (like OpenSSL integration) it should function and perform as best as we can everywhere.
- it provides complete set of functionality. If we succeed this will expose inner guts of
SslStream and it would provide functional equivalent - something we discussed with @davidfowl while back.
- It is minimalistic in API surface. This is aimed towards power consumer - for specific cases. And there is likely more code one needs to write in order to get everything right. Certificate validation is one example. Unlike
SslStream here the validation is externalized e.g. the state machine needs to be told if certificate should be trusted or now. One can still use standard X509Chain to do that but it is not forced into it. And AIA or certificate fetching should happen outside of the core loop.
API Proposal
namespace System.Net.Security;
public enum TlsOperationStatus
{
Complete = 0, // operation finished; check bytesWritten / bytesRead
WantRead = 1, // need more ciphertext from peer
WantWrite = 2, // output buffer too small; call DrainPendingOutput
Closed = 3, // peer sent close_notify
WantCredentials = 4, // client must supply a client cert
NeedsCertificateValidation = 5, // peer cert is available; caller must accept/reject
NeedsServerOptions = 6, // ClientHello parsed; caller must call SetServerContext
}
// Reusable configuration; one context can spawn many sessions concurrently.
// Options are cloned into each session at Create time, so the context is
// safe to share across threads after creation.
[Experimental("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")]
public sealed partial class TlsContext : IDisposable
{
public bool IsServer { get; }
public static TlsContext Create(SslServerAuthenticationOptions? options); // null = deferred (SNI flow)
public static TlsContext Create(SslClientAuthenticationOptions options);
public void Dispose();
}
// Per-connection TLS state machine. Two interchangeable modes:
// 1. Sans-I/O (default factory): caller moves bytes via ProcessHandshake /
// Encrypt / Decrypt. The session never touches a transport.
// 2. Socket-bound (SafeSocketHandle factory): session owns a non-blocking
// SafeSocketHandle and drives I/O itself; methods return WantRead /
// WantWrite on EAGAIN so the caller can wait on its preferred readiness
// primitive (epoll/kqueue/IOCP/Task).
[Experimental("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")]
public sealed partial class TlsSession : IDisposable
{
// -------- Factories --------
public static TlsSession Create(TlsContext context);
public static TlsSession Create(TlsContext context, SafeSocketHandle socket);
// -------- State --------
public bool IsHandshakeComplete { get; }
public bool HasPendingOutput { get; }
public string? TargetHostName { get; set; } // SNI. Client: caller sets before handshake. Server: auto-populated from parsed ClientHello (see CaptureClientHello switch).
public SslClientHelloInfo? ClientHelloInfo { get; } // server-only, populated once the ClientHello is received; stays populated until Dispose
// Raw ClientHello bytes (record header + handshake message) captured on every server session.
// Returned span is valid until Dispose. Throws InvalidOperationException on client sessions,
// before the ClientHello has been received, or on server sessions where the
// System.Net.Security.CaptureClientHello AppContext switch is disabled AND options were
// supplied at TlsContext creation. Callers who need to persist the bytes should .ToArray().
public ReadOnlySpan<byte> GetClientHelloBytes();
public SslProtocols NegotiatedProtocol { get; }
[CLSCompliant(false)]
public TlsCipherSuite NegotiatedCipherSuite { get; }
public SslApplicationProtocol NegotiatedApplicationProtocol { get; }
public X509Certificate2? LocalCertificate { get; }
public SafeSocketHandle? Socket { get; } // null in sans-I/O mode
// -------- Sans-I/O surface (mode 1) --------
public TlsOperationStatus ProcessHandshake(ReadOnlySpan<byte> input, Span<byte> output,
out int bytesConsumed, out int bytesWritten);
public TlsOperationStatus Encrypt(ReadOnlySpan<byte> plaintext, Span<byte> ciphertext,
out int bytesConsumed, out int bytesWritten);
public TlsOperationStatus Decrypt(ReadOnlySpan<byte> ciphertext, Span<byte> plaintext,
out int bytesConsumed, out int bytesWritten);
public TlsOperationStatus Shutdown(Span<byte> ciphertext, out int bytesWritten);
public TlsOperationStatus DrainPendingOutput(Span<byte> ciphertext, out int bytesWritten);
public TlsOperationStatus RequestClientCertificate(Span<byte> ciphertext, out int bytesWritten);
// -------- Socket-bound surface (mode 2) --------
public TlsOperationStatus Handshake();
public TlsOperationStatus Read(Span<byte> buffer, out int bytesRead);
public TlsOperationStatus Write(ReadOnlySpan<byte> buffer, out int bytesWritten);
// -------- Suspension points --------
public X509Certificate2? GetRemoteCertificate(); // leaf
public X509Certificate2Collection? GetRemoteCertificates(); // intermediates
public SslPolicyErrors AcceptWithDefaultValidation(); // built-in chain build + user callback
public void SetRemoteCertificateValidationResult(SslPolicyErrors errors); // caller-supplied verdict
public void SetServerContext(TlsContext serverContext); // resolve NeedsServerOptions with a pre-built context
public void SetClientCertificateContext(SslStreamCertificateContext context); // resolve WantCredentials
public IReadOnlyList<string>? GetAcceptableIssuers(); // CA hints from CertificateRequest
public ChannelBinding? GetChannelBinding(ChannelBindingKind kind); // tls-unique / tls-server-end-point
public void Dispose();
}
API Usage
TLS server over an IDuplexPipe
The motivating sans-I/O case: a server that already owns its transport
(here a duplex pipe, but the shape is the same for Memory<byte> queues,
custom message framing, etc.).
using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions
{
ServerCertificate = cert,
EnabledSslProtocols = SslProtocols.Tls13,
ApplicationProtocols = [SslApplicationProtocol.Http2, SslApplicationProtocol.Http11],
});
async Task HandleAsync(IDuplexPipe pipe)
{
using TlsSession s = TlsSession.Create(ctx);
byte[] scratch = ArrayPool<byte>.Shared.Rent(16 * 1024);
while (!s.IsHandshakeComplete)
{
ReadResult rr = await pipe.Input.ReadAsync();
SequencePosition consumedTo = rr.Buffer.Start;
foreach (ReadOnlyMemory<byte> seg in rr.Buffer)
{
var status = s.ProcessHandshake(seg.Span, scratch, out int consumed, out int produced);
consumedTo = rr.Buffer.GetPosition(consumed, consumedTo);
if (produced > 0)
await pipe.Output.WriteAsync(scratch.AsMemory(0, produced));
if (status == TlsOperationStatus.NeedsCertificateValidation)
s.AcceptWithDefaultValidation();
if (status is TlsOperationStatus.WantRead or TlsOperationStatus.Complete)
break;
}
pipe.Input.AdvanceTo(consumedTo);
}
Console.WriteLine($"Negotiated {s.NegotiatedProtocol} / ALPN={s.NegotiatedApplicationProtocol}");
// ... app data via s.Decrypt / s.Encrypt ...
}
SNI-aware server choosing options
The server creates a "bootstrap" context with null options. The first
ProcessHandshake call returns NeedsServerOptions; the caller inspects
ClientHelloInfo and resolves the right credential set, without any
async callback or re-entrancy. This is the suspend-the-state-machine
equivalent of ServerOptionsSelectionCallback, with full control of where
the lookup happens (cache, KV store, certificate service, ...).
// Long-lived per-tenant contexts (typically cached keyed by SNI + options fingerprint).
ConcurrentDictionary<string, TlsContext> contextsBySni = LoadContexts();
// Bootstrap context: no cert baked in. Sessions are created against it so the
// ClientHello can be peeked managed-side; SetServerContext then adopts the per-tenant one.
using TlsContext bootstrap = TlsContext.Create((SslServerAuthenticationOptions?)null);
using TlsSession s = TlsSession.Create(bootstrap);
var status = s.ProcessHandshake(clientHello, scratch, out _, out int produced);
if (status == TlsOperationStatus.NeedsServerOptions)
{
string? host = s.ClientHelloInfo?.ServerName;
TlsContext ctx = contextsBySni.GetValueOrDefault(host ?? "") ?? contextsBySni["default"];
s.SetServerContext(ctx);
// resume handshake on next loop iteration
}
The full raw ClientHello record is also available via s.GetClientHelloBytes() on
every server session — useful for JA3 fingerprinting, tenant routing, or audit logging.
The bytes are captured by default; the peek + parse + BIO-handoff overhead measured on
loopback is ~10-30 us per handshake and ~500 B. Anyone who wants the last ~1% can
disable capture via the System.Net.Security.CaptureClientHello AppContext switch
(also readable from DOTNET_SYSTEM_NET_SECURITY_CAPTURECLIENTHELLO=0); on server
sessions with options supplied up front, that takes the SSL_set_fd fast path and
GetClientHelloBytes() throws.
Peer-certificate inspection before accept
NeedsCertificateValidation lets the caller look at the peer certificate
together with the negotiated parameters (NegotiatedProtocol,
NegotiatedCipherSuite, NegotiatedApplicationProtocol) before deciding,
which is awkward with the current async callback because those properties
are not populated when the callback runs.
var status = s.ProcessHandshake(input, output, out _, out int produced);
if (status == TlsOperationStatus.NeedsCertificateValidation)
{
using X509Certificate2? peer = s.GetRemoteCertificate();
if (peer is not null && _revocationDirectory.IsRevoked(peer))
s.SetRemoteCertificateValidationResult(SslPolicyErrors.RemoteCertificateChainErrors);
else
s.AcceptWithDefaultValidation(); // runs default chain build + user callback
}
TLS 1.3 post-handshake client-cert request (server side)
Servers occasionally need to ask for a client certificate after the
handshake completes (e.g., a privileged operation behind an otherwise
anonymous endpoint). TLS 1.3 supports this via post-handshake
authentication and SChannel exposes SEC_I_RENEGOTIATE for the TLS 1.2
equivalent. RequestClientCertificate produces the request record; the
caller writes it to the transport and the engine reports
NeedsCertificateValidation when the response arrives.
// During app data: escalate to require a client cert.
byte[] outBuf = ArrayPool<byte>.Shared.Rent(2048);
s.RequestClientCertificate(outBuf, out int n);
await transport.WriteAsync(outBuf.AsMemory(0, n));
// Subsequent s.Decrypt(...) call will return NeedsCertificateValidation
// when the client's Certificate / CertificateVerify arrives.
Socket-bound non-blocking server
For callers who already own a non-blocking socket and would otherwise have
to build the byte-shuttling loop themselves, the socket-bound mode handles
it. The session owns the SafeSocketHandle, so Dispose cleans up the OS
handle too.
Socket client = await listener.AcceptAsync();
client.Blocking = false;
using TlsContext ctx = TlsContext.Create(serverOptions);
using TlsSession s = TlsSession.Create(ctx, client.SafeHandle); // takes ownership
while (true)
{
var status = s.Handshake();
if (status == TlsOperationStatus.Complete) break;
if (status == TlsOperationStatus.NeedsCertificateValidation) { s.AcceptWithDefaultValidation(); continue; }
if (status is TlsOperationStatus.WantRead or TlsOperationStatus.WantWrite)
await WaitOnReadinessAsync(client, status); // epoll/kqueue/Task — caller's choice
}
byte[] buf = ArrayPool<byte>.Shared.Rent(16 * 1024);
while (s.Read(buf, out int n) == TlsOperationStatus.Complete && n > 0)
s.Write(buf.AsSpan(0, n), out _);
Alternative Designs
#127928 is similar proposal but it is focusing primarily on specific use case. We could try to extend SslStream but the Stream semantic is one of the things this proposal is trying to avoid.
Alternatives considered for the deferred / SNI-callback server flow:
-
Null TlsContext on TlsSession.Create — flatten the API by letting callers write TlsSession.Create((TlsContext?)null, socket) instead of first creating a bootstrap TlsContext.Create((SslServerAuthenticationOptions?)null).
Considered but not adopted. Loses reusable identity — the bootstrap TlsContext is a first-class value that survives the process (or the listener), and once real per-tenant TlsContexts are added, sessions are steered onto them via SetServerContext. Also introduces role ambiguity — TlsSession.Create(null, socket) doesn't say server vs client, requiring another argument or overload split. And it fragments the general pattern (all other constructions are TlsContext.Create -> TlsSession.Create(ctx)).
-
Static shared bootstrap — a TlsContext.DeferredServer singleton so callers avoid even the one bootstrap allocation.
Open for API review. Safe now that per-session credential handles are properly session-local (both SetServerContext and SetClientCertificateContext acquire session-local credentials, so multiple sessions on a shared bootstrap resolving to different tenants don't race on the bootstrap's CredentialsHandle). Trade-off is the awkwardness of a static IDisposable — the singleton can never be disposed. Not currently in the proposal.
-
Named factory — TlsContext.ForDeferredServer() (or similar) as sugar over TlsContext.Create((SslServerAuthenticationOptions?)null) to avoid the null-cast.
Open for API review. Cleanest ergonomics for the SNI-dispatch use case without changing lifecycle semantics. One-line addition alongside the current overload.
Risks
The risk of the API itself is small - mostly around meeting the perf expectations. For maintainability I really want to change SslStream to work on top of this e.g. this becomes the internal PAL. With that, there is great potential for regressions if your test coverage is not good enough.
To decrease risk, we are proposing to mark it as experimental for now. That would likely be removed in next LTS e.g. .NET 12
Background and motivation
Right now, SslStream is the only way how one can do TLS in .NET. It offers
Streaminterface and it is real legacy since .NET Framework, It works fine for majority cases but there are also cases when it does not fit that well. Examples may be inside of asp.net server where the consumer does not useStreambut it is forced to consume that interface. Another example may be various databases or custom protocol where all you want is to pump data from socket (sometimes synchronously) and decrypt and consume your own framing.The
SslStreamalso inherited various craft over the years and we need to drag some forward for compatibility reasons.This is mostly minimalists API driven by performance in some niche scenarios. While they may not be common, but the benefits still may be interesting. It is very similar to #127928 but there are some significant differences.
SslStreamand it would provide functional equivalent - something we discussed with @davidfowl while back.SslStreamhere the validation is externalized e.g. the state machine needs to be told if certificate should be trusted or now. One can still use standardX509Chainto do that but it is not forced into it. And AIA or certificate fetching should happen outside of the core loop.API Proposal
API Usage
TLS server over an
IDuplexPipeThe motivating sans-I/O case: a server that already owns its transport
(here a duplex pipe, but the shape is the same for
Memory<byte>queues,custom message framing, etc.).
SNI-aware server choosing options
The server creates a "bootstrap" context with
nulloptions. The firstProcessHandshakecall returnsNeedsServerOptions; the caller inspectsClientHelloInfoand resolves the right credential set, without anyasync callback or re-entrancy. This is the suspend-the-state-machine
equivalent of
ServerOptionsSelectionCallback, with full control of wherethe lookup happens (cache, KV store, certificate service, ...).
The full raw ClientHello record is also available via
s.GetClientHelloBytes()onevery server session — useful for JA3 fingerprinting, tenant routing, or audit logging.
The bytes are captured by default; the peek + parse + BIO-handoff overhead measured on
loopback is ~10-30 us per handshake and ~500 B. Anyone who wants the last ~1% can
disable capture via the
System.Net.Security.CaptureClientHelloAppContext switch(also readable from
DOTNET_SYSTEM_NET_SECURITY_CAPTURECLIENTHELLO=0); on serversessions with options supplied up front, that takes the SSL_set_fd fast path and
GetClientHelloBytes()throws.Peer-certificate inspection before accept
NeedsCertificateValidationlets the caller look at the peer certificatetogether with the negotiated parameters (
NegotiatedProtocol,NegotiatedCipherSuite,NegotiatedApplicationProtocol) before deciding,which is awkward with the current async callback because those properties
are not populated when the callback runs.
TLS 1.3 post-handshake client-cert request (server side)
Servers occasionally need to ask for a client certificate after the
handshake completes (e.g., a privileged operation behind an otherwise
anonymous endpoint). TLS 1.3 supports this via post-handshake
authentication and SChannel exposes
SEC_I_RENEGOTIATEfor the TLS 1.2equivalent.
RequestClientCertificateproduces the request record; thecaller writes it to the transport and the engine reports
NeedsCertificateValidationwhen the response arrives.Socket-bound non-blocking server
For callers who already own a non-blocking socket and would otherwise have
to build the byte-shuttling loop themselves, the socket-bound mode handles
it. The session owns the
SafeSocketHandle, soDisposecleans up the OShandle too.
Alternative Designs
#127928 is similar proposal but it is focusing primarily on specific use case. We could try to extend
SslStreambut theStreamsemantic is one of the things this proposal is trying to avoid.Alternatives considered for the deferred / SNI-callback server flow:
Null
TlsContextonTlsSession.Create— flatten the API by letting callers writeTlsSession.Create((TlsContext?)null, socket)instead of first creating a bootstrapTlsContext.Create((SslServerAuthenticationOptions?)null).Considered but not adopted. Loses reusable identity — the bootstrap
TlsContextis a first-class value that survives the process (or the listener), and once real per-tenantTlsContexts are added, sessions are steered onto them viaSetServerContext. Also introduces role ambiguity —TlsSession.Create(null, socket)doesn't say server vs client, requiring another argument or overload split. And it fragments the general pattern (all other constructions areTlsContext.Create -> TlsSession.Create(ctx)).Static shared bootstrap — a
TlsContext.DeferredServersingleton so callers avoid even the one bootstrap allocation.Open for API review. Safe now that per-session credential handles are properly session-local (both
SetServerContextandSetClientCertificateContextacquire session-local credentials, so multiple sessions on a shared bootstrap resolving to different tenants don't race on the bootstrap'sCredentialsHandle). Trade-off is the awkwardness of a staticIDisposable— the singleton can never be disposed. Not currently in the proposal.Named factory —
TlsContext.ForDeferredServer()(or similar) as sugar overTlsContext.Create((SslServerAuthenticationOptions?)null)to avoid the null-cast.Open for API review. Cleanest ergonomics for the SNI-dispatch use case without changing lifecycle semantics. One-line addition alongside the current overload.
Risks
The risk of the API itself is small - mostly around meeting the perf expectations. For maintainability I really want to change
SslStreamto work on top of this e.g. this becomes the internal PAL. With that, there is great potential for regressions if your test coverage is not good enough.To decrease risk, we are proposing to mark it as experimental for now. That would likely be removed in next LTS e.g. .NET 12