Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

[API Proposal]: low level TLS machine #128871

Copy link
Copy link

Description

@wfurt
Issue body actions

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:

  1. 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)).

  2. 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.

  3. Named factoryTlsContext.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

Reactions are currently unavailable

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    Morty Proxy This is a proxified and sanitized view of the page, visit original site.