diff --git a/TlsSession-proposal.md b/TlsSession-proposal.md
new file mode 100644
index 00000000000000..c87d4fd36e4652
--- /dev/null
+++ b/TlsSession-proposal.md
@@ -0,0 +1,870 @@
+# API Proposal: `TlsSession` — Non-Blocking TLS State Machine
+
+## Summary
+
+A new low-level, non-blocking, span-based TLS API in `System.Net.Security` that
+exposes the TLS state machine directly. The caller drives I/O; the session
+handles only encryption, decryption, and the handshake protocol. The same
+session type optionally supports binding to a `SafeSocketHandle`, in which case
+it performs socket I/O itself — on Linux via OpenSSL's `SSL_set_fd` fast path,
+on other platforms via an internal pump over the same state machine.
+
+The API is the synchronous primitive on which higher-level adapters
+(`Stream`, `IDuplexPipe`, and ultimately `SslStream` itself) are layered.
+
+This proposal is a refinement of [#127928] in response to feedback from
+@bartonjs and @rzikm. Key shape changes from that proposal:
+
+- Single `TlsSession` type (no `TlsDetachedSession` / `TlsSocketBoundSession` split).
+- Cross-platform API surface. Linux is an implementation fast path, not a separate type.
+- Role (client vs. server) bound to `TlsContext` via the options type; no `bool isServer`.
+- Two explicit suspension states (`NeedsServerOptions`, `NeedsCertificateValidation`)
+ replace async callbacks running on the I/O thread.
+- No internal certificate validation. Caller is responsible for the trust decision;
+ `AcceptWithDefaultValidation()` extension preserves `SslStream`'s default behavior.
+
+[#127928]: https://github.com/dotnet/runtime/issues/127928
+
+## Background and Motivation
+
+`SslStream` is the only public TLS API in .NET today. It bundles three concerns:
+
+1. The TLS state machine (handshake, encrypt/decrypt records, alerts, renegotiation).
+2. An async I/O loop that reads ciphertext from an inner `Stream` and writes
+ plaintext out.
+3. A `Stream`-shaped public surface.
+
+For high-throughput servers — most notably Kestrel — that bundling forces two
+costs:
+
+- **Buffer copies.** On Linux, ciphertext goes `kernel → managed buffer →
+ BIO_write → OpenSSL → SSL_read out → managed buffer`. Two memcpys per read
+ (and symmetrically per write) that have nothing to do with cryptography.
+- **An adapter at the consumer boundary.** Kestrel's transport surface is
+ `IDuplexPipe`. Wrapping a `Stream`-shaped TLS connection in a
+ `StreamPipeReader` / `StreamPipeWriter` re-introduces buffer copies between
+ Pipe segments and byte arrays — and means TLS connections take a
+ fundamentally different code path through Kestrel than plaintext ones.
+
+There is also no way today to:
+
+- Drive the TLS state machine from a custom I/O loop (epoll, io_uring, custom
+ thread-per-core, etc.) without a parallel re-implementation of the OpenSSL
+ P/Invoke layer.
+- Perform certificate validation asynchronously without blocking an I/O thread
+ inside `RemoteCertificateValidationCallback`.
+- Resolve SNI-based server cert selection without a callback that holds up the
+ handshake.
+
+The existing `SslStream` PAL is already span-in / span-out and synchronous —
+`InitializeSecurityContext` / `AcceptSecurityContext` / `EncryptMessage` /
+`DecryptMessage` on every backing provider (OpenSSL, Schannel, Apple/managed).
+The async loop lives entirely above the PAL in `SslStream.IO.cs`. This proposal
+exposes the PAL-level state machine as a public type, then re-hosts `SslStream`
+on top of it. There is one TLS state machine implementation in the box, not two.
+
+## Goals
+
+- Public, non-blocking TLS state machine on top of the existing PAL.
+- Cross-platform contract: same API on Windows, Linux, macOS.
+- No internal I/O abstraction; caller chooses Stream, Pipe, raw socket, kTLS,
+ io_uring, whatever.
+- Optional socket-bound mode that exploits OpenSSL's `SSL_set_fd` on Linux
+ without forcing other platforms onto a Linux-only API.
+- Explicit suspension model for async cert validation and SNI-based server
+ cert selection — no callbacks running on the I/O thread.
+- Reuse existing `SslServerAuthenticationOptions` / `SslClientAuthenticationOptions`;
+ no new configuration surface to learn.
+- One implementation. `SslStream` is re-hosted on top of `TlsSession`.
+
+## Non-Goals
+
+- A built-in async I/O loop (that's what adapter types are for).
+- A built-in certificate validation policy (that's an opt-in helper).
+- A new exception hierarchy.
+- An immediate Schannel/macOS equivalent of `SSL_set_fd` (impossible; not needed).
+
+---
+
+## API Surface
+
+All new public API surface is marked with `[Experimental("SYSLIB5007")]` for now.
+
+```csharp
+namespace System.Net.Security;
+
+public enum TlsOperationStatus
+{
+ /// The call made forward progress. Check bytesConsumed/bytesWritten.
+ Complete = 0,
+
+ /// The session needs more ciphertext from the peer to make progress.
+ WantRead = 1,
+
+ ///
+ /// The session has ciphertext to send. Call
+ /// before retrying, or hand the output span from the current call to the transport.
+ ///
+ WantWrite = 2,
+
+ /// The transport is gone or close_notify was received. Dispose the session.
+ Closed = 3,
+
+ ///
+ /// Client-side only. The server sent a CertificateRequest and the session has no
+ /// certificate context. Fetch a certificate (possibly out-of-band), call
+ /// , then retry.
+ /// Use to inspect the server's hints.
+ ///
+ WantCredentials = 4,
+
+ ///
+ /// The peer presented a certificate awaiting the caller's verdict. Inspect
+ /// / ,
+ /// then either call for the
+ /// same policy uses by default, or perform custom checks and
+ /// call with the verdict.
+ ///
+ NeedsCertificateValidation = 5,
+
+ ///
+ /// Server-side only. The peer's ClientHello has been received but server options
+ /// have not been supplied. Inspect ,
+ /// call , then retry.
+ ///
+ NeedsServerOptions = 6,
+}
+
+///
+/// Long-lived TLS configuration. Thread-safe; create once per logical endpoint
+/// (or per virtual host) and share across many instances.
+/// The context owns the underlying SSL_CTX (or its per-platform equivalent) and its
+/// session-ticket cache.
+///
+public sealed class TlsContext : IDisposable
+{
+ ///
+ /// Creates a server context. Pass to defer options until
+ /// the ClientHello arrives - the caller then inspects the SNI on
+ /// and steers the session onto a
+ /// per-tenant context via .
+ ///
+ public static TlsContext Create(SslServerAuthenticationOptions? options);
+
+ public static TlsContext Create(SslClientAuthenticationOptions options);
+
+ public bool IsServer { get; }
+
+ public void Dispose();
+}
+
+///
+/// A per-connection TLS session driving a non-blocking TLS state machine.
+/// In detached mode the caller performs all socket I/O via /
+/// / . In socket-bound mode
+/// () the session reads and writes on
+/// the bound socket via / / ;
+/// on Linux this uses OpenSSL's fd-binding fast path with no managed-side
+/// ciphertext copies.
+///
+public sealed class TlsSession : IDisposable
+{
+ // ── Construction ──────────────────────────────────────────────────────
+
+ /// Creates a detached session. The caller drives all I/O.
+ public static TlsSession Create(TlsContext context);
+
+ ///
+ /// Creates a socket-bound session. The session reads and writes on
+ /// . The socket must be non-blocking. The session takes
+ /// ownership of the handle for the lifetime of the session.
+ ///
+ public static TlsSession Create(TlsContext context, SafeSocketHandle socket);
+
+ // ── Identity / state ──────────────────────────────────────────────────
+
+ /// The bound socket handle, or for detached sessions.
+ public SafeSocketHandle? Socket { get; }
+
+ public bool IsHandshakeComplete { get; }
+
+ ///
+ /// SNI / target hostname. On client sessions the caller sets this before the first
+ /// handshake call (or supplies it via SslClientAuthenticationOptions.TargetHost).
+ /// On server sessions it is automatically populated from the parsed ClientHello SNI
+ /// extension once the ClientHello has been received (subject to the
+ /// System.Net.Security.CaptureClientHello switch — see ).
+ ///
+ public string? TargetHostName { get; set; }
+
+ // ── Negotiated info (valid after IsHandshakeComplete) ─────────────────
+
+ public SslProtocols NegotiatedProtocol { get; }
+ public TlsCipherSuite NegotiatedCipherSuite { get; }
+ public SslApplicationProtocol NegotiatedApplicationProtocol { get; }
+
+ ///
+ /// The certificate the session presented to the peer (server cert on server sessions,
+ /// client cert on client sessions with mTLS). when the local
+ /// side did not present a certificate.
+ ///
+ public X509Certificate2? LocalCertificate { get; }
+
+ ///
+ /// The certificate the peer presented (raw, unvalidated). May be non-null before
+ /// the handshake completes and before the caller has accepted it.
+ ///
+ public X509Certificate2? GetRemoteCertificate();
+
+ ///
+ /// The full certificate collection the peer sent (leaf + intermediates), or
+ /// when the peer did not present a certificate.
+ ///
+ public X509Certificate2Collection? GetRemoteCertificates();
+
+ /// RFC 5929 channel binding token.
+ public ChannelBinding? GetChannelBinding(ChannelBindingKind kind);
+
+ // ── ClientHello data (server-side, populated once the ClientHello is received) ─
+
+ ///
+ /// Parsed ClientHello (SNI + supported versions). Populated on every server session
+ /// once the ClientHello has been received and remains populated until
+ /// . before the ClientHello arrives, on
+ /// client sessions, and on server sessions where capture is disabled (see below).
+ ///
+ public SslClientHelloInfo? ClientHelloInfo { get; }
+
+ ///
+ /// Returns the raw ClientHello record bytes (5-byte TLS record header plus the
+ /// ClientHello handshake message). The span is valid until ;
+ /// callers who need to persist the bytes should .ToArray().
+ ///
+ ///
+ /// Thrown 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 creation.
+ ///
+ public ReadOnlySpan GetClientHelloBytes();
+
+ // ── Suspension resolvers ──────────────────────────────────────────────
+
+ ///
+ /// Server-side only. Resolves a session suspended on
+ /// by adopting the supplied
+ /// . The context's SSL_CTX (with its ticket cache and
+ /// per-tenant options) backs the session for the rest of the handshake and its
+ /// steady state. Callers that maintain a pool of virtual-host contexts (keyed by
+ /// SNI + options fingerprint) use this to steer the session onto the pre-warmed
+ /// context matching the ClientHello.
+ ///
+ public void SetServerContext(TlsContext serverContext);
+
+ ///
+ /// Client-side only. Supplies the certificate the session should send in response
+ /// to the server's CertificateRequest. Resolves a session suspended on
+ /// . May also be called before
+ /// the first handshake call to seed the client credential when the
+ /// was created without one.
+ ///
+ public void SetClientCertificateContext(SslStreamCertificateContext context);
+
+ ///
+ /// Client-side only. Returns the acceptable issuer hints the server sent in its
+ /// CertificateRequest, or if none were sent. Meaningful while
+ /// suspended on .
+ ///
+ public IReadOnlyList? GetAcceptableIssuers();
+
+ ///
+ /// Runs the same chain build + hostname match + policy as 's
+ /// default validation over the peer certificate and returns the resulting
+ /// . Also stamps the verdict onto the session, so
+ /// the next call resumes the suspended handshake (Finished on accept, fatal alert
+ /// on any error flag set).
+ ///
+ public SslPolicyErrors AcceptWithDefaultValidation();
+
+ ///
+ /// Supplies the verdict for the peer certificate from a custom validation. Pass
+ /// to accept, or any error flags to reject with
+ /// a fatal bad_certificate alert. Throws if the session is not currently
+ /// suspended on .
+ ///
+ public void SetRemoteCertificateValidationResult(SslPolicyErrors errors);
+
+ // ── State machine (detached mode) ─────────────────────────────────────
+
+ /// Advances the handshake by one step.
+ public TlsOperationStatus ProcessHandshake(
+ ReadOnlySpan input, Span output,
+ out int bytesConsumed, out int bytesWritten);
+
+ ///
+ /// Decrypts one record's worth of ciphertext. Throws
+ /// before the handshake is complete.
+ ///
+ public TlsOperationStatus Decrypt(
+ ReadOnlySpan ciphertext, Span plaintext,
+ out int bytesConsumed, out int bytesWritten);
+
+ ///
+ /// Encrypts plaintext into ciphertext. Throws
+ /// before the handshake is complete.
+ ///
+ public TlsOperationStatus Encrypt(
+ ReadOnlySpan plaintext, Span ciphertext,
+ out int bytesConsumed, out int bytesWritten);
+
+ ///
+ /// True when the session has ciphertext for the caller to send to the peer
+ /// (handshake messages, alerts, KeyUpdate responses, close_notify).
+ ///
+ public bool HasPendingOutput { get; }
+
+ ///
+ /// Copies pending ciphertext into . If the span was
+ /// too small to drain everything, returns ;
+ /// call again with a fresh buffer.
+ ///
+ public TlsOperationStatus DrainPendingOutput(Span ciphertext, out int bytesWritten);
+
+ // ── Socket-bound convenience ──────────────────────────────────────────
+ // Throw InvalidOperationException on detached sessions.
+
+ public TlsOperationStatus Handshake();
+ public TlsOperationStatus Read(Span buffer, out int bytesRead);
+ public TlsOperationStatus Write(ReadOnlySpan buffer, out int bytesWritten);
+
+ // ── Shutdown / advanced ───────────────────────────────────────────────
+
+ ///
+ /// Emits a TLS close_notify alert into .
+ /// The caller sends the produced bytes to the peer. Returns
+ /// if the buffer was too small for the alert record; call again with a larger buffer.
+ ///
+ public TlsOperationStatus Shutdown(Span ciphertext, out int bytesWritten);
+
+ ///
+ /// Server-side only. Emits a TLS 1.3 post-handshake CertificateRequest into
+ /// . Subsequent calls drive
+ /// the auth flow and may surface
+ /// when the client's Certificate message arrives.
+ ///
+ public TlsOperationStatus RequestClientCertificate(Span ciphertext, out int bytesWritten);
+
+ public void Dispose();
+}
+```
+
+---
+
+## Contract
+
+### Status semantics
+
+| Status | Meaning | Caller action |
+|---|---|---|
+| `Complete` | Call made progress | Check `bytesWritten` / `bytesConsumed`; continue if more work expected |
+| `WantRead` | Need more ciphertext from peer | `recv` from transport; append; retry |
+| `WantWrite` | Pending output must be flushed | `DrainPendingOutput`; send to peer; retry |
+| `Closed` | Transport gone or `close_notify` received | Dispose |
+| `WantCredentials` | Client cert requested, none supplied | Fetch cert (`GetAcceptableIssuers` for hints); call `SetClientCertificateContext`; retry |
+| `NeedsCertificateValidation` | Peer cert presented, awaiting verdict | Inspect cert; call `AcceptWithDefaultValidation` OR custom check + `SetRemoteCertificateValidationResult`; retry |
+| `NeedsServerOptions` | ClientHello received, options not supplied | Inspect `ClientHelloInfo`; call `SetServerContext`; retry |
+
+### Loop invariants
+
+1. **One step per call.** Any method returns at the first checkpoint. The caller
+ loops; the session never blocks.
+2. **`WantWrite` always takes priority.** The session does not consume new input
+ while pending output is non-empty.
+3. **`WantRead` / `WantWrite` describe transport direction**, not which API
+ method to call next.
+4. **Suspension is durable.** While suspended on a `Needs…` state, the session's
+ internal state is preserved until the caller supplies the verdict (or disposes).
+
+### Renegotiation, KeyUpdate, alerts
+
+These surface as `WantWrite` (with the relevant ciphertext appearing in
+`DrainPendingOutput`) and, if necessary, `WantRead` for the peer's response.
+The caller's existing read/write loop handles them with no special-casing.
+
+For TLS 1.2 renegotiation that re-presents the peer cert,
+`NeedsCertificateValidation` may surface from `Decrypt` (not just
+`ProcessHandshake`). Same protocol applies.
+
+### Certificate validation timing
+
+The handshake **suspends before** the client's Finished (or the server's
+Finished in the mTLS case). The caller supplies the verdict via
+`AcceptWithDefaultValidation()` (which runs the same chain build, hostname
+match, and revocation policy as `SslStream` uses by default) or
+`SetRemoteCertificateValidationResult(SslPolicyErrors)` for a custom check.
+
+If the verdict is reject (any error flag set), the session emits a fatal
+alert at exactly the right protocol state and the peer never observes a
+successful handshake. No application data is ever exchanged with a rejected
+peer. This matches the recent `SslStream` fix that ensured the validation
+callback's verdict actually gates the Finished.
+
+### Error model
+
+Reuses existing exception types:
+
+| Condition | Exception |
+|---|---|
+| Handshake protocol error (bad cert, alert from peer, version mismatch) | `AuthenticationException` |
+| Unrecoverable I/O error after handshake | `IOException` |
+| `Read` / `Write` before `IsHandshakeComplete` | `InvalidOperationException` |
+| `SetServerContext` while not suspended on `NeedsServerOptions` | `InvalidOperationException` |
+| `SetRemoteCertificateValidationResult` while not suspended on `NeedsCertificateValidation` | `InvalidOperationException` |
+| `SetClientCertificateContext` on a server session | `InvalidOperationException` |
+| `GetClientHelloBytes` when unavailable (client session, before ClientHello, or capture disabled) | `InvalidOperationException` |
+| Socket-bound API called on detached session (and vice versa) | `InvalidOperationException` |
+| Suspension feature unsupported on current platform (e.g. OpenSSL 1.1.1 cert-validation pause) | `PlatformNotSupportedException` |
+
+---
+
+## Platform Implementation
+
+| Suspension | Linux (OpenSSL ≥ 3.0) | Linux (OpenSSL 1.1.1) | Windows (Schannel) | macOS (managed) |
+|---|---|---|---|---|
+| `NeedsServerOptions` | `SSL_CTX_set_client_hello_cb` → `SSL_CLIENT_HELLO_RETRY` | Same | Pre-parse ClientHello, defer first `AcceptSecurityContext` | Our state machine |
+| `NeedsCertificateValidation` | `set_cert_verify_callback` returning `-1` (`SSL_ERROR_WANT_RETRY_VERIFY`) | `PlatformNotSupportedException` — caller validates post-handshake | `SCH_CRED_MANUAL_CRED_VALIDATION`; we buffer Finished output | Our state machine |
+
+OpenSSL 1.1.1 is EOL September 2026; the `PlatformNotSupportedException` window is small.
+
+For the socket-bound path:
+
+| Aspect | Linux (OpenSSL) | Other |
+|---|---|---|
+| `SSL_set_fd` direct path | Yes | No (internal pump over `Decrypt` / `Encrypt`) |
+| Zero managed ciphertext copy | Yes | No |
+| Same public API | Yes | Yes |
+
+The cross-platform contract is "the session does the socket I/O for you." Linux
+additionally gets "and the TLS provider owns the syscall, with no managed-side
+copy" as an implementation optimization. There are no `[SupportedOSPlatform]`
+annotations on the public surface.
+
+### ClientHello capture
+
+On server sessions the ClientHello record is peeked managed-side (via a
+socket-replay BIO on Linux, natively on other PALs) so that `ClientHelloInfo`,
+`TargetHostName`, and `GetClientHelloBytes()` are populated the same way
+regardless of whether the caller uses the SNI-callback flow
+(`TlsContext.Create((SslServerAuthenticationOptions?)null)` +
+`SetServerContext`) or supplies options up front. The retained peek buffer is
+handed to the SSL* as its read BIO after `SetServerContext`, so the handshake
+then runs unchanged from OpenSSL's point of view.
+
+The measured overhead of peek + parse + BIO-handoff vs. the pre-capture
+`SSL_set_fd` fast path is ~10-30 µs per handshake and ~500 B (loopback bench,
+long-run pinned, TLS 1.2 and TLS 1.3 both full and resumed). That's inside the
+noise floor of a real-network handshake, so capture is always on by default.
+
+**Opt-out**: `System.Net.Security.CaptureClientHello` (`AppContext` switch, also
+readable from `DOTNET_SYSTEM_NET_SECURITY_CAPTURECLIENTHELLO=0`), cached once at
+process init. Only meaningful on server sessions with options supplied up front —
+the deferred / SNI-callback and buffered / managed-loop paths always capture
+because they parse the ClientHello anyway. When the switch is off,
+`GetClientHelloBytes()` throws `InvalidOperationException` on server sessions that
+took the pure-fd fast path; `ClientHelloInfo` and `TargetHostName` behave as they
+did pre-capture on that path.
+
+---
+
+## Examples
+
+### Detached client over an arbitrary `Socket`
+
+```csharp
+using System.Buffers;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+
+static async Task SendRequestAsync(string host, int port, string request, CancellationToken ct)
+{
+ using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ await socket.ConnectAsync(host, port, ct);
+
+ using var ctx = TlsContext.Create(new SslClientAuthenticationOptions
+ {
+ TargetHost = host,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ ApplicationProtocols = [SslApplicationProtocol.Http11],
+ });
+
+ using var session = TlsSession.Create(ctx);
+ session.TargetHostName = host;
+
+ byte[] netIn = ArrayPool.Shared.Rent(16 * 1024);
+ byte[] netOut = ArrayPool.Shared.Rent(16 * 1024);
+ byte[] plain = ArrayPool.Shared.Rent(16 * 1024);
+ int inUsed = 0;
+
+ try
+ {
+ // Handshake
+ while (!session.IsHandshakeComplete)
+ {
+ var status = session.ProcessHandshake(
+ netIn.AsSpan(0, inUsed), netOut,
+ out int consumed, out int produced);
+ Consume(netIn, ref inUsed, consumed);
+
+ if (produced > 0)
+ await socket.SendAsync(netOut.AsMemory(0, produced), ct);
+
+ switch (status)
+ {
+ case TlsOperationStatus.Complete:
+ continue;
+
+ case TlsOperationStatus.WantWrite:
+ await DrainAsync(session, socket, netOut, ct);
+ continue;
+
+ case TlsOperationStatus.WantRead:
+ int r = await socket.ReceiveAsync(netIn.AsMemory(inUsed), ct);
+ if (r == 0) throw new IOException("Connection closed during handshake.");
+ inUsed += r;
+ continue;
+
+ case TlsOperationStatus.NeedsCertificateValidation:
+ // Run validation off the I/O thread; AcceptWithDefaultValidation both
+ // performs the check and stamps the verdict onto the session.
+ var errors = await Task.Run(session.AcceptWithDefaultValidation, ct);
+ if (errors != SslPolicyErrors.None)
+ {
+ await DrainAsync(session, socket, netOut, ct); // flush fatal alert
+ throw new AuthenticationException($"Server cert rejected: {errors}");
+ }
+ continue;
+
+ case TlsOperationStatus.Closed:
+ throw new IOException("Peer closed the connection during handshake.");
+ }
+ }
+
+ // Send the request, read the response (omitted for brevity — same loop shape
+ // as the handshake, calling Encrypt / Decrypt instead of ProcessHandshake).
+ return await ExchangeAsync(session, socket, Encoding.ASCII.GetBytes(request), netIn, netOut, plain, ct);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(netIn);
+ ArrayPool.Shared.Return(netOut);
+ ArrayPool.Shared.Return(plain);
+ }
+}
+
+static async Task DrainAsync(TlsSession session, Socket socket, byte[] buffer, CancellationToken ct)
+{
+ while (session.HasPendingOutput)
+ {
+ var s = session.DrainPendingOutput(buffer, out int n);
+ if (n > 0) await socket.SendAsync(buffer.AsMemory(0, n), ct);
+ if (s != TlsOperationStatus.WantWrite) break;
+ }
+}
+
+static void Consume(byte[] buf, ref int used, int n)
+{
+ if (n == 0) return;
+ if (n < used) Buffer.BlockCopy(buf, n, buf, 0, used - n);
+ used -= n;
+}
+```
+
+### Socket-bound server with SNI-based cert selection
+
+```csharp
+// One-time, shared across the listener.
+ConcurrentDictionary contextsBySni = LoadContexts();
+// Bootstrap context (no cert). The session is created against it so the ClientHello can
+// be peeked managed-side; SetServerContext then adopts the per-tenant context.
+TlsContext bootstrap = TlsContext.Create((SslServerAuthenticationOptions?)null);
+
+async Task HandleAsync(Socket client, CancellationToken ct)
+{
+ client.Blocking = false;
+ using var session = TlsSession.Create(bootstrap, client.SafeHandle);
+
+ // Handshake — readiness pump.
+ while (!session.IsHandshakeComplete)
+ {
+ var status = session.Handshake();
+ switch (status)
+ {
+ case TlsOperationStatus.Complete:
+ break;
+
+ case TlsOperationStatus.WantRead:
+ await PollReadableAsync(client, ct);
+ break;
+
+ case TlsOperationStatus.WantWrite:
+ await PollWritableAsync(client, ct);
+ break;
+
+ case TlsOperationStatus.NeedsServerOptions:
+ var hello = session.ClientHelloInfo!.Value;
+ var ctx = contextsBySni.GetValueOrDefault(hello.ServerName)
+ ?? contextsBySni["default"];
+ session.SetServerContext(ctx);
+ break;
+
+ case TlsOperationStatus.Closed:
+ return;
+ }
+ }
+
+ // Steady state — session does recv/send itself.
+ byte[] buf = ArrayPool.Shared.Rent(16 * 1024);
+ try
+ {
+ while (true)
+ {
+ var rs = session.Read(buf, out int n);
+ if (rs == TlsOperationStatus.WantRead) { await PollReadableAsync(client, ct); continue; }
+ if (rs == TlsOperationStatus.WantWrite) { await PollWritableAsync(client, ct); continue; }
+ if (rs == TlsOperationStatus.Closed || n == 0) return;
+
+ await HandleRequestAsync(session, buf.AsMemory(0, n), ct);
+ }
+ }
+ finally { ArrayPool.Shared.Return(buf); }
+}
+```
+
+### Migration from `SslStream` (one-line default validation)
+
+```csharp
+// What the new code looks like for the common case:
+case TlsOperationStatus.NeedsCertificateValidation:
+ if (session.AcceptWithDefaultValidation() != SslPolicyErrors.None)
+ {
+ // Optional: log the rejection. The session has already stamped the
+ // reject verdict; the next call flushes the fatal alert.
+ }
+ continue;
+```
+
+`AcceptWithDefaultValidation` performs the same chain build, hostname match,
+and revocation policy that `SslStream` uses by default and returns the
+resulting `SslPolicyErrors`. `SslPolicyErrors.None` accepts, anything else
+rejects. Anyone who didn't pass a custom `RemoteCertificateValidationCallback`
+to `SslStream` gets equivalent behavior with this single `case`.
+
+---
+
+## Adapter Types
+
+`TlsSession` is the primitive. Two adapters ship alongside it in
+`System.Net.Security` to give each ecosystem the shape it expects without
+forcing the primitive to know about either.
+
+### `TlsDuplexPipe` — `IDuplexPipe` wrapper
+
+For Kestrel-style consumers. Wraps an `IDuplexPipe` transport and produces an
+`IDuplexPipe` of plaintext. Internally owns a `TlsSession` and runs two
+background pumps (inbound: transport → `Decrypt` → plaintext pipe; outbound:
+plaintext pipe → `Encrypt` → transport). Exposes async-shaped callbacks for
+the suspension states, because async is natural at the pipe layer.
+
+```csharp
+public sealed class TlsDuplexPipe : IDuplexPipe, IAsyncDisposable
+{
+ public static ValueTask CreateAsync(
+ TlsContext context,
+ IDuplexPipe transport,
+ TlsDuplexPipeOptions? options = null,
+ CancellationToken cancellationToken = default);
+
+ public PipeReader Input { get; }
+ public PipeWriter Output { get; }
+
+ // Session metadata after handshake.
+ public SslProtocols NegotiatedProtocol { get; }
+ public TlsCipherSuite NegotiatedCipherSuite { get; }
+ public SslApplicationProtocol NegotiatedApplicationProtocol { get; }
+ public X509Certificate2? GetRemoteCertificate();
+ // ...
+
+ public ValueTask DisposeAsync();
+}
+
+public sealed class TlsDuplexPipeOptions
+{
+ public string? TargetHostName { get; set; }
+ public Func>? ServerOptionsSelector { get; set; }
+ public Func>? CertificateValidator { get; set; }
+ public PipeOptions? InputPipeOptions { get; set; }
+ public PipeOptions? OutputPipeOptions { get; set; }
+}
+```
+
+### `Stream` adapter
+
+For `SslStream` migrants who want the new contract but still consume via
+`Stream`. Either an extension method (`session.AsStream(Stream transport)`) or
+a thin sealed `Stream` wrapper. Implementation is ~150 lines on top of the
+public `TlsSession` API.
+
+### `SslStream` re-hosting
+
+Once the primitive lands, `SslStream`'s implementation is rewritten on top of
+`TlsSession`:
+
+- `ForceAuthenticationAsync` becomes a loop over `ProcessHandshake`.
+- `ReadAsyncInternal` becomes a loop over `Decrypt` (+ `DrainPendingOutput` on
+ `WantWrite`, await the inner stream on `WantRead`).
+- `WriteAsyncInternal` becomes a loop over `Encrypt`.
+- `RemoteCertificateValidationCallback` is invoked from the
+ `NeedsCertificateValidation` case.
+- `ServerOptionsSelectionCallback` is invoked from the
+ `NeedsServerOptions` case.
+
+The provider-specific PAL layer (`SslStreamPal.Unix.cs`,
+`SslStreamPal.Windows.cs`, the managed PAL) is shared between `TlsSession` and
+the rehosted `SslStream`. There is one TLS state machine implementation in the
+box.
+
+---
+
+## Open Questions
+
+1. **OpenSSL 1.1.1 fallback for `NeedsCertificateValidation`.** `PlatformNotSupportedException`
+ (current proposal) vs. silently disable the suspension and require post-handshake
+ validation. The exception is more honest but is a hard breakage for the minor
+ slice of users still on 1.1.1. Recommendation: exception, given 1.1.1 EOL.
+
+2. **`X509RevocationMode` default in `AcceptWithDefaultValidation`.** Match
+ `SslStream` historical default (`NoCheck`) vs. modern best practice
+ (`Online`). Current proposal: match `SslStream`. Easy to change later as a
+ default-value adjustment.
+
+3. **Where does `TlsDuplexPipe` live?** Same assembly as `TlsSession`
+ (`System.Net.Security`) vs. a separate package. Current proposal: same
+ assembly, since `System.IO.Pipelines` is already an inbox shared framework
+ dependency on .NET.
+
+4. **`Stream` adapter shape.** Extension method (`session.AsStream(transport)`)
+ vs. dedicated public type. Recommendation: extension method returning a
+ private sealed `Stream`.
+
+5. **Async cancellation in socket-bound mode.** `Read` / `Write` /
+ `Handshake` don't take `CancellationToken` (they're synchronous non-blocking).
+ Cancellation is "dispose the session." Confirm this is acceptable.
+
+6. **Telemetry.** Hook `TlsSession` into `NetSecurityTelemetry` the same way
+ `SslStream` is today. Mostly mechanical, but worth confirming the event
+ shape (e.g. do we want a `tls.session.kind = "detached" | "socket-bound"`
+ tag).
+
+7. **macOS Network.framework future.** A future macOS PAL on
+ `nw_protocol_options_tls_*` could give us a Linux-style fast path
+ (Network.framework owns the socket). The unified type accommodates this
+ without an API change. Confirm we're comfortable not committing to it now.
+
+---
+
+## Functional Comparison with `SslStream`
+
+### Covered with no surface gap
+
+- Handshake (client / server), read, write, shutdown.
+- Negotiated protocol / cipher / ALPN / session-resumed / peer cert.
+- SNI (`TargetHostName`, populated server-side from the parsed ClientHello).
+- Raw ClientHello bytes via `GetClientHelloBytes()` (server-side; JA3 fingerprinting, tenant routing, audit logging).
+- Renegotiation, TLS 1.3 KeyUpdate, NewSessionTicket, alerts (handled implicitly).
+- Channel binding (`GetChannelBinding`).
+- All `SslServer/ClientAuthenticationOptions` configuration knobs.
+
+### Covered with explicit suspension protocol
+
+- `ServerOptionsSelectionCallback` → `NeedsServerOptions` + `SetServerContext`.
+- `RemoteCertificateValidationCallback` → `NeedsCertificateValidation` +
+ `AcceptWithDefaultValidation` / `SetRemoteCertificateValidationResult`.
+- `LocalCertificateSelectionCallback` → `WantCredentials` + `SetClientCertificateContext`
+ (with `GetAcceptableIssuers` for the server's CertificateRequest hints).
+- Post-handshake authentication (TLS 1.3 PHA) → `RequestClientCertificate`.
+
+### Layered above the primitive
+
+- `Stream` shape → adapter.
+- `IDuplexPipe` shape → `TlsDuplexPipe`.
+- Default validation policy is a member method (`AcceptWithDefaultValidation`),
+ not an extension, because it needs to stamp the verdict onto the session
+ state to resume the suspended handshake.
+
+### Deliberate omissions
+
+- Internal certificate validation (caller's responsibility).
+- `SslPolicyErrors` enum on the API (no internal validation means no errors to report).
+- Async configuration callbacks running on the I/O thread (replaced by suspension).
+- `bool isServer` parameter (inferred from `TlsContext` options type).
+
+---
+
+## Implementation Sketch
+
+Approximate scope, assuming the existing PAL stays:
+
+1. **New status codes.** Add `WantRead` / `WantWrite` (distinct from today's
+ collapsed `OK`) and the two `Needs…` codes to `SecurityStatusPalErrorCode`
+ plumbing. Map in each PAL's `MapNativeErrorCode`. Small.
+
+2. **Suspension wiring.**
+ - Linux: register `client_hello_cb` and `cert_verify_callback` on
+ `SSL_CTX`; thread suspension state through the OpenSSL `SSL*` app-data
+ slot.
+ - Windows: pre-parse SNI from the ClientHello (`TlsFrameHelper` already
+ does this); set `SCH_CRED_MANUAL_CRED_VALIDATION`; gate handshake output
+ drain on validation verdict.
+ - macOS managed: add explicit states to the state machine.
+
+3. **`TlsContext` / `TlsSession` types.** New ref-source entries, new
+ implementation files in `System.Net.Security`. Roughly mirror
+ `SafeDeleteSslContext` ownership patterns.
+
+4. **`SslStream` re-hosting.** Rewrite `SslStream.IO.cs` on top of
+ `TlsSession`. Mostly mechanical; the existing async loop becomes a thinner
+ driver over the new public API.
+
+5. **Adapter types.** `TlsDuplexPipe` and the `Stream` adapter. Pure managed
+ code on top of `TlsSession`.
+
+6. **Tests.** Re-purpose existing `SslStream` interop tests against
+ `TlsSession` directly, plus new tests for the suspension protocol on each
+ platform.
+
+No new P/Invokes are required for the detached / cross-platform path. The
+socket-bound Linux path adds one (`SSL_set_fd`); other platforms' socket-bound
+mode is implemented entirely in managed code over `Decrypt` / `Encrypt`.
+
+---
+
+## Appendix A: Comparison with the Original `SafeTlsContextHandle` Proposal
+
+[#127928] originally proposed two `SafeHandle` types
+(`SafeTlsContextHandle`, `SafeTlsHandle`) and a separate split between
+`TlsDetachedSession` / `TlsSocketBoundSession`. The shape evolved as follows:
+
+| Original | Now | Rationale |
+|---|---|---|
+| `SafeTlsContextHandle` : `SafeHandle` with public instance methods | `TlsContext` : `IDisposable` | `SafeHandle` with public instance API is unusual; it's really a `TlsContext` object that happens to hold a handle (@bartonjs) |
+| `SafeTlsHandle` : `SafeHandle` | `TlsSession` : `IDisposable` | Same reasoning |
+| Two session types (`TlsDetachedSession` / `TlsSocketBoundSession`) | One `TlsSession` with two factories | The session contract is identical; socket binding is an implementation detail of *one* method group |
+| `[SupportedOSPlatform("linux")]` on socket-bound type | No platform annotation; Linux is a fast path | Other platforms implement socket-bound mode via internal pump; same API on every platform (@rzikm) |
+| `bool isServer` | Inferred from options type at `TlsContext` creation | Removes a class of mismatched-options bugs |
+| Async config via callbacks running on the I/O thread | Explicit suspension states | Decouples validation work from the I/O thread; integrates cleanly with async/await; cancellation is "dispose" |
+| Internal default validation | No validation; caller decides | True primitive; default policy is opt-in via extension method |
+
+The shape that survived is the one that fits cleanly under both
+`SslStream` (rehosted) and `TlsDuplexPipe` (new adapter) — i.e. it's the
+extracted PAL contract, polished and made public.
diff --git a/docs/project/list-of-diagnostics.md b/docs/project/list-of-diagnostics.md
index 9375539382eece..d037a73b11fdba 100644
--- a/docs/project/list-of-diagnostics.md
+++ b/docs/project/list-of-diagnostics.md
@@ -319,3 +319,4 @@ Diagnostic id values for experimental APIs must not be recycled, as that could s
| __`SYSLIB5004`__ | .NET 9 | TBD | `X86Base.DivRem` is experimental since performance is not as optimized as `T.DivRem` |
| __`SYSLIB5005`__ | .NET 9 | .NET 10 | `System.Formats.Nrbf` is experimental |
| __`SYSLIB5006`__ | .NET 10 | TBD | Types for Post-Quantum Cryptography (PQC) are experimental. |
+| __`SYSLIB5007`__ | .NET 10 | TBD | Low-level TLS engine types (`TlsContext`, `TlsSession`) in `System.Net.Security` are experimental. |
diff --git a/src/libraries/Common/src/Interop/OSX/Interop.NetworkFramework.Tls.cs b/src/libraries/Common/src/Interop/OSX/Interop.NetworkFramework.Tls.cs
index ee216667d6a06a..0821b46ea0b479 100644
--- a/src/libraries/Common/src/Interop/OSX/Interop.NetworkFramework.Tls.cs
+++ b/src/libraries/Common/src/Interop/OSX/Interop.NetworkFramework.Tls.cs
@@ -28,7 +28,7 @@ internal static unsafe partial bool Init(
// Create a new connection context
[LibraryImport(Interop.Libraries.AppleCryptoNative, EntryPoint = "AppleCryptoNative_NwConnectionCreate", StringMarshalling = StringMarshalling.Utf8)]
- internal static unsafe partial SafeNwHandle NwConnectionCreate([MarshalAs(UnmanagedType.I4)] bool isServer, IntPtr context, string targetName, byte* alpnBuffer, int alpnLength, SslProtocols minTlsProtocol, SslProtocols maxTlsProtocol, uint* cipherSuites, int cipherSuitesLength);
+ internal static unsafe partial SafeNwHandle NwConnectionCreate([MarshalAs(UnmanagedType.I4)] bool isServer, IntPtr context, string targetName, byte* alpnBuffer, int alpnLength, SslProtocols minTlsProtocol, SslProtocols maxTlsProtocol, uint* cipherSuites, int cipherSuitesLength, IntPtr serverIdentity);
// Start the TLS handshake, notifications are received via the status callback (potentially from a different thread).
[LibraryImport(Interop.Libraries.AppleCryptoNative, EntryPoint = "AppleCryptoNative_NwConnectionStart")]
diff --git a/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.OpenSsl.cs b/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.OpenSsl.cs
index f6e59847109d69..e002dfb7eeb8ee 100644
--- a/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.OpenSsl.cs
+++ b/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.OpenSsl.cs
@@ -189,13 +189,13 @@ private static SslProtocols CalculateEffectiveProtocols(SslAuthenticationOptions
return protocols;
}
- internal static SafeSslContextHandle GetOrCreateSslContextHandle(SslAuthenticationOptions sslAuthenticationOptions, bool allowCached)
+ internal static SafeSslContextHandle GetOrCreateSslContextHandle(SslAuthenticationOptions sslAuthenticationOptions, bool allowCached, bool enableResume)
{
SslProtocols protocols = CalculateEffectiveProtocols(sslAuthenticationOptions);
if (!allowCached)
{
- return AllocateSslContext(sslAuthenticationOptions, protocols, allowCached);
+ return AllocateSslContext(sslAuthenticationOptions, protocols, enableResume);
}
bool hasAlpn = sslAuthenticationOptions.ApplicationProtocols != null && sslAuthenticationOptions.ApplicationProtocols.Count != 0;
@@ -208,9 +208,9 @@ internal static SafeSslContextHandle GetOrCreateSslContextHandle(SslAuthenticati
sslAuthenticationOptions.CertificateContext);
return s_sslContexts.GetOrCreate(key, static (args) =>
{
- var (sslAuthOptions, protocols, allowCached) = args;
- return AllocateSslContext(sslAuthOptions, protocols, allowCached);
- }, (sslAuthenticationOptions, protocols, allowCached));
+ var (sslAuthOptions, protocols, enableResume) = args;
+ return AllocateSslContext(sslAuthOptions, protocols, enableResume);
+ }, (sslAuthenticationOptions, protocols, enableResume));
}
// This essentially wraps SSL_CTX* aka SSL_CTX_new + setting
@@ -361,7 +361,16 @@ internal static void UpdateClientCertificate(SafeSslHandle ssl, SslAuthenticatio
internal static unsafe SafeSslHandle AllocateSslHandle(SslAuthenticationOptions sslAuthenticationOptions)
{
SafeSslHandle? sslHandle = null;
- bool cacheSslContext = sslAuthenticationOptions.AllowTlsResume && !LocalAppContextSwitches.DisableTlsResume && sslAuthenticationOptions.EncryptionPolicy == EncryptionPolicy.RequireEncryption && sslAuthenticationOptions.CipherSuitesPolicy == null;
+ // When a TlsContext owns a long-lived SSL_CTX (set via PreallocatedSslContext)
+ // we bypass the global SslContextCacheKey lookup and the TLS-resume cache hung
+ // off it: the TlsContext is the resume scope. The handle is borrowed here, not
+ // owned, so the conditional Dispose() in the finally below skips it.
+ SafeSslContextHandle? preallocatedSslCtx = sslAuthenticationOptions.PreallocatedSslContext;
+ bool cacheSslContext = preallocatedSslCtx is null
+ && sslAuthenticationOptions.AllowTlsResume
+ && !LocalAppContextSwitches.DisableTlsResume
+ && sslAuthenticationOptions.EncryptionPolicy == EncryptionPolicy.RequireEncryption
+ && sslAuthenticationOptions.CipherSuitesPolicy == null;
if (cacheSslContext)
{
@@ -398,9 +407,13 @@ internal static unsafe SafeSslHandle AllocateSslHandle(SslAuthenticationOptions
// For uncached SafeSslContextHandles, the handle will be disposed and closed.
// Cached SafeSslContextHandles are returned with increaset rent count so that
// Dispose() here will not close the handle.
- using SafeSslContextHandle sslCtxHandle = GetOrCreateSslContextHandle(sslAuthenticationOptions, cacheSslContext);
-
- sslHandle = SafeSslHandle.Create(sslCtxHandle, sslAuthenticationOptions);
+ // When a preallocated SSL_CTX is provided (TlsContext-owned), we borrow it
+ // for the duration of this method without disposing — the TlsContext keeps
+ // it alive across every TlsSession it produces.
+ SafeSslContextHandle sslCtxHandle = preallocatedSslCtx ?? GetOrCreateSslContextHandle(sslAuthenticationOptions, cacheSslContext, cacheSslContext);
+ try
+ {
+ sslHandle = SafeSslHandle.Create(sslCtxHandle, sslAuthenticationOptions);
Debug.Assert(sslHandle != null, "Expected non-null return value from SafeSslHandle.Create");
if (sslHandle.IsInvalid)
{
@@ -524,6 +537,14 @@ internal static unsafe SafeSslHandle AllocateSslHandle(SslAuthenticationOptions
}
}
}
+ }
+ finally
+ {
+ if (preallocatedSslCtx is null)
+ {
+ sslCtxHandle.Dispose();
+ }
+ }
return sslHandle;
}
@@ -735,6 +756,15 @@ internal static unsafe SecurityStatusPalErrorCode DoSslHandshake(SafeSslHandle c
return SecurityStatusPalErrorCode.CredentialsNeeded;
}
+ if (errorCode == Ssl.SslErrorCode.SSL_ERROR_WANT_RETRY_VERIFY)
+ {
+ // OpenSSL 3.0+ retry-verify: the certificate verification
+ // callback paused the handshake. The application owns
+ // certificate validation and must resume the handshake
+ // (by calling DoSslHandshake again) once it has a verdict.
+ return SecurityStatusPalErrorCode.CertValidationNeeded;
+ }
+
if (errorCode == Ssl.SslErrorCode.SSL_ERROR_SSL && context.CertificateValidationException is Exception ex)
{
// Clear the OpenSSL error queue since we are using our own
@@ -1007,7 +1037,29 @@ internal static int CertVerifyCallback(IntPtr storeCtx, IntPtr arg)
.TryGetTarget(out SslAuthenticationOptions? options);
Debug.Assert(options != null, "Expected to get SslAuthenticationOptions from GCHandle");
- sslHandle = (SafeSslHandle)options!.SslStream!._securityContext!;
+ sslHandle = options!.SafeSslHandle as SafeSslHandle;
+ Debug.Assert(sslHandle is not null, "Expected SslAuthenticationOptions.SafeSslHandle to be set by SafeSslHandle.Create");
+
+ // No in-callback validator (TlsSession path): accept the certificate here so
+ // the TLS handshake completes, then surface the peer cert to the caller via
+ // NeedsCertificateValidation on the next ProcessHandshake. Any subsequent
+ // Encrypt/Decrypt blocks until the caller posts a verdict.
+ //
+ // Ideally we would pause the handshake via SSL_set_retry_verify on OpenSSL 3.0+
+ // so a caller's reject can emit a fatal TLS alert to the peer mid-handshake.
+ // In practice SSL_set_retry_verify is not honored for peer-cert verification
+ // on either client or server SSLs in current upstream OpenSSL (the callback is
+ // not re-entered after SSL_do_handshake resumes). The native shim
+ // CryptoNative_SslSetRetryVerify, the SafeSslHandle.RetryVerifyAttempted /
+ // ExternalValidationAccepted fields, and TlsSession's PushExternalValidation-
+ // VerdictToPalIfRetryVerify are kept in place as dormant infrastructure; once
+ // upstream OpenSSL honors retry-verify, gate the SSL_set_retry_verify path in
+ // this branch behind a version check (or feature probe) to opt into it.
+ if (options.RemoteCertificateValidator is null)
+ {
+ Ssl.X509StoreCtxSetError(storeCtx, (int)Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_OK);
+ return 1;
+ }
// We need to note the number of certs in ExtraStore that were
// provided (by the user), we will add more from the received peer
@@ -1021,7 +1073,9 @@ internal static int CertVerifyCallback(IntPtr storeCtx, IntPtr arg)
try
{
ProtocolToken alertToken = default;
- if (options.SslStream!.VerifyRemoteCertificate(certificate, chain, options.CertificateContext?.Trust, ref alertToken, out SslPolicyErrors sslPolicyErrors, out X509ChainStatusFlags chainStatus))
+ SslAuthenticationOptions.VerifyRemoteCertificateCallback? validator = options.RemoteCertificateValidator;
+ Debug.Assert(validator is not null, "Expected SslAuthenticationOptions.RemoteCertificateValidator to be set by SslStream or TlsSession");
+ if (validator!(certificate, chain, options.CertificateContext?.Trust, ref alertToken, out SslPolicyErrors sslPolicyErrors, out X509ChainStatusFlags chainStatus))
{
Ssl.X509StoreCtxSetError(storeCtx, (int)Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_OK);
return 1;
diff --git a/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.Ssl.cs b/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.Ssl.cs
index e241786d4d8483..73517e96d11084 100644
--- a/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.Ssl.cs
+++ b/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.Ssl.cs
@@ -6,6 +6,7 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Net.Security;
+using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
@@ -117,6 +118,12 @@ internal static unsafe ushort[] GetDefaultSignatureAlgorithms()
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslSetBio")]
internal static partial void SslSetBio(SafeSslHandle ssl, SafeBioHandle rbio, SafeBioHandle wbio);
+ [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslSetFd", SetLastError = true)]
+ internal static partial int SslSetFd(SafeSslHandle ssl, SafeSocketHandle socket);
+
+ [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslDoHandshake", SetLastError = true)]
+ internal static partial int SslDoHandshake(SafeSslHandle ssl, out SslErrorCode error);
+
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslHandshake", SetLastError = true)]
internal static unsafe partial int SslHandshake(
SafeSslHandle ssl,
@@ -166,6 +173,38 @@ internal static unsafe partial int SslDecrypt(
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_BioNewManagedSpan")]
internal static partial SafeBioHandle BioNewManagedSpan();
+ [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_BioNewSocketReplay")]
+ private static unsafe partial SafeBioHandle BioNewSocketReplay(IntPtr fd, byte* prefix, int prefixLen);
+
+ internal static unsafe SafeBioHandle BioNewSocketReplay(SafeSocketHandle socket, ReadOnlySpan prefix)
+ {
+ fixed (byte* pPrefix = prefix)
+ {
+ return BioNewSocketReplay(socket.DangerousGetHandle(), pPrefix, prefix.Length);
+ }
+ }
+
+ // Reads directly from the BIO's bound fd into its internal peek buffer until a
+ // full TLS record is present. Returns:
+ // 1 = have full frame; framePtr / frameLen point into the BIO's buffer.
+ // 0 = need more data (fd would block); caller polls SelectRead and retries.
+ // -1 = error (EOF, oversized record, or recv failure).
+ //
+ // The returned pointer is valid until the BIO is destroyed or SocketReplayBioRead
+ // starts consuming the buffer (i.e. once SSL_do_handshake runs against this BIO).
+ // Callers must span-wrap and parse before creating the SSL* that owns the BIO.
+ [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_BioReadTlsFrame")]
+ internal static unsafe partial int BioReadTlsFrame(SafeBioHandle bio, out byte* framePtr, out int frameLen);
+
+ // Returns the socket-replay BIO's retained peek buffer (bytes captured by
+ // BioReadTlsFrame). Valid until the BIO is destroyed, even after OpenSSL has
+ // drained it during handshake.
+ // 1 = prefix present; prefixPtr / prefixLen wrap the internal buffer.
+ // 0 = BIO has no captured prefix.
+ // -1 = error (invalid args).
+ [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_BioGetPrefix")]
+ internal static unsafe partial int BioGetPrefix(SafeBioHandle bio, out byte* prefixPtr, out int prefixLen);
+
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_BioGetWriteResult")]
internal static partial void BioGetWriteResult(SafeBioHandle bio, out int writtenToWindow, out int spillLen);
@@ -231,6 +270,9 @@ internal static SafeSharedX509StackHandle SslGetPeerCertChain(SafeSslHandle ssl)
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslSetVerifyPeer")]
internal static partial void SslSetVerifyPeer(SafeSslHandle ssl, [MarshalAs(UnmanagedType.Bool)] bool failIfNoPeerCert);
+ [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslSetRetryVerify")]
+ internal static partial int SslSetRetryVerify(SafeSslHandle ssl);
+
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslGetData")]
internal static partial IntPtr SslGetData(IntPtr ssl);
@@ -422,6 +464,7 @@ internal enum SslErrorCode
SSL_ERROR_WANT_X509_LOOKUP = 4,
SSL_ERROR_SYSCALL = 5,
SSL_ERROR_ZERO_RETURN = 6,
+ SSL_ERROR_WANT_RETRY_VERIFY = 12,
// NOTE: this SslErrorCode value doesn't exist in OpenSSL, but
// we use it to distinguish when a renegotiation is pending.
@@ -450,6 +493,17 @@ internal sealed class SafeSslHandle : SafeDeleteSslContext
// we may rethrow it after returning to managed code.
public Exception? CertificateValidationException;
+ // OpenSSL 3.0+ retry-verify state (dormant infrastructure). When CertVerifyCallback
+ // eventually opts into SSL_set_retry_verify (currently disabled — upstream OpenSSL
+ // does not re-enter the peer-cert verify callback on either client or server SSLs),
+ // it will set RetryVerifyAttempted = true and, on re-entry, honor
+ // ExternalValidationAccepted (posted by TlsSession.PushExternalValidationVerdict-
+ // ToPalIfRetryVerify). Both fields are read but never written today, hence CS0649.
+#pragma warning disable CS0649
+ public bool RetryVerifyAttempted;
+ public bool ExternalValidationAccepted;
+#pragma warning restore CS0649
+
public bool IsServer
{
get { return _isServer; }
@@ -478,13 +532,43 @@ internal void MarkHandshakeCompleted()
public static SafeSslHandle Create(SafeSslContextHandle context, SslAuthenticationOptions options)
{
- SafeBioHandle readBio = Interop.Ssl.BioNewManagedSpan();
- SafeBioHandle writeBio = Interop.Ssl.BioNewManagedSpan();
+ SafeSocketHandle? socket = options.SocketHandle;
+ bool useFd = socket is not null && !socket.IsInvalid;
+ SafeBioHandle? preallocatedReadBio = useFd ? options.PreallocatedReadBio : null;
+ byte[]? replayPrefix = useFd ? options.ReplayPrefix : null;
+ bool usePreallocatedBio = preallocatedReadBio is not null;
+ bool useReplayBio = usePreallocatedBio || (useFd && replayPrefix is not null);
+
+ SafeBioHandle? readBio = null;
+ SafeBioHandle? writeBio = null;
+ if (usePreallocatedBio)
+ {
+ // Deferred-server flow (native pre-fetch): the caller populated a
+ // socket-replay BIO via BioReadTlsFrame; adopt it as the read BIO
+ // and create a peer write BIO for OpenSSL's outbound records.
+ // Clear the field so ownership transfer happens exactly once.
+ readBio = preallocatedReadBio;
+ options.PreallocatedReadBio = null;
+ writeBio = Interop.Ssl.BioNewSocketReplay(socket!, ReadOnlySpan.Empty);
+ }
+ else if (useReplayBio)
+ {
+ // Legacy deferred-server flow (managed pre-fetch): install a socket-
+ // replay BIO seeded with the peeked ClientHello bytes.
+ readBio = Interop.Ssl.BioNewSocketReplay(socket!, replayPrefix);
+ writeBio = Interop.Ssl.BioNewSocketReplay(socket!, ReadOnlySpan.Empty);
+ }
+ else if (!useFd)
+ {
+ readBio = Interop.Ssl.BioNewManagedSpan();
+ writeBio = Interop.Ssl.BioNewManagedSpan();
+ }
+
SafeSslHandle handle = Interop.Ssl.SslCreate(context);
- if (readBio.IsInvalid || writeBio.IsInvalid || handle.IsInvalid)
+ if (((readBio is not null) && (readBio.IsInvalid || writeBio!.IsInvalid)) || handle.IsInvalid)
{
- readBio.Dispose();
- writeBio.Dispose();
+ readBio?.Dispose();
+ writeBio?.Dispose();
handle.Dispose(); // will make IsInvalid==true if it's not already
return handle;
}
@@ -492,23 +576,41 @@ public static SafeSslHandle Create(SafeSslContextHandle context, SslAuthenticati
handle._authOptionsHandle = new WeakGCHandle(options);
Interop.Ssl.SslSetData(handle, WeakGCHandle.ToIntPtr(handle._authOptionsHandle));
- // SslSetBio will transfer ownership of the BIO handles to the SSL context
- try
+ // CertVerifyCallback needs the SafeSslHandle to stash a
+ // CertificateValidationException; expose it via the options.
+ options.SafeSslHandle = handle;
+
+ if (useFd && !useReplayBio)
{
- readBio.TransferOwnershipToParent(handle);
- writeBio.TransferOwnershipToParent(handle);
- handle._readBio = readBio;
- handle._writeBio = writeBio;
- Interop.Ssl.SslSetBio(handle, readBio, writeBio);
+ if (Interop.Ssl.SslSetFd(handle, socket!) != 1)
+ {
+ handle.Dispose();
+ throw Interop.OpenSsl.CreateSslException(SR.net_allocate_ssl_context_failed);
+ }
}
- catch (Exception exc)
+ else
{
- // The only way this should be able to happen without thread aborts is if we hit OOMs while
- // manipulating the safe handles, in which case we may leak the bio handles.
- Debug.Fail("Unexpected exception while transferring SafeBioHandle ownership to SafeSslHandle", exc.ToString());
- throw;
+ // SslSetBio will transfer ownership of the BIO handles to the SSL context
+ try
+ {
+ readBio!.TransferOwnershipToParent(handle);
+ writeBio!.TransferOwnershipToParent(handle);
+ handle._readBio = readBio;
+ handle._writeBio = writeBio;
+ Interop.Ssl.SslSetBio(handle, readBio, writeBio);
+ }
+ catch (Exception exc)
+ {
+ // The only way this should be able to happen without thread aborts is if we hit OOMs while
+ // manipulating the safe handles, in which case we may leak the bio handles.
+ Debug.Fail("Unexpected exception while transferring SafeBioHandle ownership to SafeSslHandle", exc.ToString());
+ throw;
+ }
}
+ // Consumed exactly once: the BIO holds its own copy of the prefix bytes.
+ options.ReplayPrefix = null;
+
if (options.IsServer)
{
Interop.Ssl.SslSetAcceptState(handle);
diff --git a/src/libraries/Common/src/System/Experimentals.cs b/src/libraries/Common/src/System/Experimentals.cs
index caeea798d6654d..3a30f81830e9f8 100644
--- a/src/libraries/Common/src/System/Experimentals.cs
+++ b/src/libraries/Common/src/System/Experimentals.cs
@@ -33,6 +33,9 @@ internal static class Experimentals
// Types for Post-Quantum Cryptography (PQC) are experimental.
internal const string PostQuantumCryptographyDiagId = "SYSLIB5006";
+ // Low-level TLS engine (TlsContext / TlsSession) is experimental.
+ internal const string LowLevelTlsDiagId = "SYSLIB5007";
+
// When adding a new diagnostic ID, add it to the table in docs\project\list-of-diagnostics.md as well.
// Keep new const identifiers above this comment.
}
diff --git a/src/libraries/System.Net.Security/perf/TlsHandshakeBench/Program.cs b/src/libraries/System.Net.Security/perf/TlsHandshakeBench/Program.cs
new file mode 100644
index 00000000000000..3a04d7e92caa41
--- /dev/null
+++ b/src/libraries/System.Net.Security/perf/TlsHandshakeBench/Program.cs
@@ -0,0 +1,921 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Buffers;
+using System.IO;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Jobs;
+using BenchmarkDotNet.Running;
+using BenchmarkDotNet.Toolchains.InProcess.Emit;
+
+using Microsoft.Win32.SafeHandles;
+
+public static class Program
+{
+ public static void Main(string[] args)
+ {
+ if (args.Length > 0 && args[0] == "--trace")
+ {
+ int n = args.Length > 1 ? int.Parse(args[1]) : 3;
+ SslProtocols proto = args.Length > 2 && args[2] == "Tls12" ? SslProtocols.Tls12 : SslProtocols.Tls13;
+ bool allowResume = args.Length > 3 && bool.Parse(args[3]);
+ string mode = args.Length > 4 ? args[4] : "buffered"; // buffered | fd
+ TraceHarness.Run(n, proto, allowResume, mode).GetAwaiter().GetResult();
+ return;
+ }
+ IConfig config = DefaultConfig.Instance
+ .AddJob(Job.Default
+ .WithToolchain(InProcessEmitToolchain.Instance)
+ // Cap iteration count so resumed handshakes (~10 us) don't blow
+ // past BDN's InProcessEmit 10 s budget while auto-scaling.
+ // InvocationCount kept low so the per-op estimate from
+ // WorkloadJitting (TLS 1.3 cold init can be ~100 ms) doesn't
+ // make BDN reject the iteration as "takes too long".
+ .WithIterationCount(15)
+ .WithWarmupCount(5)
+ .WithInvocationCount(64)
+ .WithUnrollFactor(1))
+ .WithOptions(ConfigOptions.DisableOptimizationsValidator);
+ BenchmarkSwitcher.FromAssembly(typeof(TlsHandshakeBench).Assembly).Run(args, config);
+ }
+}
+
+internal static class Probe
+{
+ public static int Receives;
+ public static int Sends;
+ public static int PollRead;
+ public static int PollWrite;
+ public static int ProcessHandshakeCalls;
+ public static int DrainCalls;
+ public static long BytesReceived;
+ public static long BytesSent;
+
+ public static void Reset()
+ {
+ Receives = Sends = PollRead = PollWrite = ProcessHandshakeCalls = DrainCalls = 0;
+ BytesReceived = BytesSent = 0;
+ }
+
+ public static string Dump(string label)
+ => $"{label,-32} recv={Receives,3} bytes={BytesReceived,5} send={Sends,3} bytes={BytesSent,5} pollR={PollRead,3} pollW={PollWrite,3} proc={ProcessHandshakeCalls,3} drain={DrainCalls,3}";
+}
+
+[MemoryDiagnoser]
+public class TlsHandshakeBench
+{
+ private const int ScratchSize = 32 * 1024;
+ private const string ServerName = "tlsbench.local";
+
+ private static TlsBufferSession NewBufferSession(TlsContext ctx)
+ {
+ TlsBufferSession s = new TlsBufferSession();
+ s.SetContext(ctx);
+ return s;
+ }
+
+ private static TlsSocketSession NewSocketSession(TlsContext ctx, SafeSocketHandle handle)
+ {
+ TlsSocketSession s = new TlsSocketSession(handle);
+ s.SetContext(ctx);
+ return s;
+ }
+
+ private static TlsHandshakeBench? s_current;
+
+ private X509Certificate2 _cert = null!;
+ private SslServerAuthenticationOptions _serverOptions = null!;
+ private SslClientAuthenticationOptions _clientOptions = null!;
+ private TlsContext _ctxBuffered = null!;
+ private TlsContext _ctxFd = null!;
+ private TlsContext _ctxFdDeferred = null!;
+ private IPEndPoint _listenerEp = null!;
+ private Socket _listener = null!;
+
+ [Params(SslProtocols.Tls12, SslProtocols.Tls13)]
+ public SslProtocols Protocol { get; set; }
+
+ // When true, both sides opt into TLS session resumption (session tickets on TLS 1.2
+ // and PSK-based resumption on TLS 1.3). Resumption is only effective when all sessions
+ // share a single TlsContext / SSL_CTX (which this bench does for the TlsSession_* rows).
+ [Params(false, true)]
+ public bool AllowResume { get; set; }
+
+ // Toggles the internal LocalAppContextSwitches.s_captureClientHello field via
+ // reflection so we exercise both code paths. When true (default), the server takes
+ // the peek + parse + BIO-handoff path even with options up front. When false, the
+ // Fd path uses the pre-capture SSL_set_fd shortcut.
+ [Params(true, false)]
+ public bool CaptureClientHello { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ s_current = this;
+ // The switch value is cached on first read into LocalAppContextSwitches.s_captureClientHello
+ // (tri-state int: 0 = unread, 1 = true, -1 = false). Flip it via reflection so the bench
+ // can measure both code paths without launching separate processes.
+ System.Reflection.FieldInfo? switchField = typeof(TlsSession).Assembly
+ .GetType("System.LocalAppContextSwitches", throwOnError: false)
+ ?.GetField("s_captureClientHello", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
+ switchField?.SetValue(null, CaptureClientHello ? 1 : -1);
+ _cert = CreateSelfSignedCert();
+
+ _serverOptions = new SslServerAuthenticationOptions
+ {
+ ServerCertificate = _cert,
+ ClientCertificateRequired = false,
+ EnabledSslProtocols = Protocol,
+ AllowTlsResume = AllowResume,
+ };
+ _clientOptions = new SslClientAuthenticationOptions
+ {
+ TargetHost = ServerName,
+ EnabledSslProtocols = Protocol,
+ RemoteCertificateValidationCallback = static (_, _, _, _) => true,
+ AllowTlsResume = AllowResume,
+ };
+
+ // TlsContext is allocated once and reused; SSL_CTX caching is the design point.
+ _ctxBuffered = TlsContext.CreateServer(_serverOptions);
+ _ctxFd = TlsContext.CreateServer(_serverOptions);
+ // Bootstrap context used by the deferred socket-bound flow: no cert baked in,
+ // sessions are created against it and then re-parented to a per-tenant TlsContext
+ // via SetContext(...) once the ClientHello arrives.
+ _ctxFdDeferred = TlsContext.CreateServer(new SslServerAuthenticationOptions());
+
+ _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ _listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ _listener.Listen(128);
+ _listenerEp = (IPEndPoint)_listener.LocalEndPoint!;
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ _listener?.Dispose();
+ _ctxBuffered?.Dispose();
+ _ctxFd?.Dispose();
+ _ctxFdDeferred?.Dispose();
+ _cert?.Dispose();
+ }
+
+ // Baseline: SslStream on both sides over loopback TCP.
+ [Benchmark(Baseline = true)]
+ public async Task SslStream_Server()
+ {
+ (Socket cs, Socket ss) = await ConnectPairAsync();
+ using var clientStream = new NetworkStream(cs, ownsSocket: true);
+ using var serverStream = new NetworkStream(ss, ownsSocket: true);
+ using var client = new SslStream(clientStream, leaveInnerStreamOpen: false);
+ using var server = new SslStream(serverStream, leaveInnerStreamOpen: false);
+
+ Task c = client.AuthenticateAsClientAsync(_clientOptions);
+ Task s = server.AuthenticateAsServerAsync(_serverOptions);
+ await Task.WhenAll(c, s);
+ if (!client.IsAuthenticated || !server.IsAuthenticated) throw new IOException("sslstream handshake not complete");
+
+ await SslStreamPingPongAsync(client, server);
+ }
+ // TlsSession on the server, driven through the managed buffered path
+ // (ProcessHandshake + DrainPendingOutput) over a non-blocking socket.
+ // Client is SslStream.
+ [Benchmark]
+ public async Task TlsSession_Buffered_Server()
+ {
+ (Socket cs, Socket ss) = await ConnectPairAsync();
+ ss.Blocking = false;
+
+ using var clientStream = new NetworkStream(cs, ownsSocket: true);
+ using var client = new SslStream(clientStream, leaveInnerStreamOpen: false);
+ using TlsBufferSession session = NewBufferSession(_ctxBuffered);
+
+ Task c = ClientHandshakeThenPingPongAsync(client);
+ Task s = RunOnDedicatedThreadAsync(() => DriveBufferedHandshakeAndPingPong(session, ss));
+ await Task.WhenAll(c, s);
+ if (!session.IsHandshakeComplete) throw new IOException("server buffered handshake not complete");
+ if (!client.IsAuthenticated) throw new IOException("client buffered handshake not complete");
+ ss.Dispose();
+ }
+
+ // TlsSession on the server, bound directly to the socket fd via SSL_set_fd.
+ // Linux/FreeBSD only — on Windows this throws PlatformNotSupportedException.
+ [Benchmark]
+ public async Task TlsSession_Fd_Server()
+ {
+ (Socket cs, Socket ss) = await ConnectPairAsync();
+ ss.Blocking = false;
+
+ using var clientStream = new NetworkStream(cs, ownsSocket: true);
+ using var client = new SslStream(clientStream, leaveInnerStreamOpen: false);
+ using TlsSocketSession session = NewSocketSession(_ctxFd, ss.SafeHandle);
+
+ Task c = ClientHandshakeThenPingPongAsync(client);
+ Task s = RunOnDedicatedThreadAsync(() => DriveFdHandshakeAndPingPong(session, ss));
+ await Task.WhenAll(c, s);
+ if (!session.IsHandshakeComplete) throw new IOException("server fd handshake not complete");
+ if (!client.IsAuthenticated) throw new IOException("client fd handshake not complete");
+
+ // session owns ss.SafeHandle; ss itself becomes unusable, so no explicit close needed.
+ GC.KeepAlive(ss);
+ }
+
+ // Socket-bound TlsSession in the deferred-SetContext flow: bootstrap TlsContext
+ // (no cert) is used to create the session; after the ClientHello arrives the caller
+ // inspects session.ClientHelloInfo and calls SetContext with a pre-built
+ // per-tenant TlsContext (bench reuses _ctxFd for simplicity, but the pattern is one
+ // TlsContext per virtual host / cert).
+ [Benchmark]
+ public async Task TlsSession_Fd_Deferred_Server()
+ {
+ (Socket cs, Socket ss) = await ConnectPairAsync();
+ ss.Blocking = false;
+
+ using var clientStream = new NetworkStream(cs, ownsSocket: true);
+ using var client = new SslStream(clientStream, leaveInnerStreamOpen: false);
+ using TlsSocketSession session = NewSocketSession(_ctxFdDeferred, ss.SafeHandle);
+
+ Task c = ClientHandshakeThenPingPongAsync(client);
+ Task s = RunOnDedicatedThreadAsync(() => DriveFdDeferredHandshakeAndPingPong(session, ss));
+ await Task.WhenAll(c, s);
+ if (!session.IsHandshakeComplete) throw new IOException("server fd deferred handshake not complete");
+ if (!client.IsAuthenticated) throw new IOException("client fd deferred handshake not complete");
+
+ GC.KeepAlive(ss);
+ }
+
+ // BDN's InProcessEmit drives the workload from a single thread via blocking
+ // wait; using Task.Run for the server side competes for thread-pool threads
+ // with SslStream's continuations and can starve under tight measurement loops.
+ // A dedicated thread sidesteps the issue.
+ private static Task RunOnDedicatedThreadAsync(Action action)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var t = new Thread(() =>
+ {
+ try { action(); tcs.SetResult(); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ }) { IsBackground = true };
+ t.Start();
+ return tcs.Task;
+ }
+
+ private async ValueTask<(Socket Client, Socket Server)> ConnectPairAsync()
+ {
+ var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ client.NoDelay = true;
+ Task acceptTask = _listener.AcceptAsync();
+ await client.ConnectAsync(_listenerEp);
+ Socket server = await acceptTask;
+ server.NoDelay = true;
+ return (client, server);
+ }
+
+ private static async Task ClientHandshakeThenPingPongAsync(SslStream client)
+ {
+ TlsHandshakeBench bench = s_current!;
+ await client.AuthenticateAsClientAsync(bench._clientOptions);
+ if (!client.IsAuthenticated) throw new IOException("client handshake not complete");
+ // Send 1 byte, read 1 byte. This drains any post-handshake server messages
+ // (TLS 1.3 NewSessionTicket) and validates end-to-end data flow.
+ byte[] one = new byte[1] { 0xAB };
+ await client.WriteAsync(one);
+ byte[] rx = new byte[1];
+ int n = await client.ReadAsync(rx);
+ if (n != 1 || rx[0] != 0xCD) throw new IOException($"client ping/pong failed n={n}");
+ }
+
+ private static async Task SslStreamPingPongAsync(SslStream client, SslStream server)
+ {
+ byte[] one = new byte[1] { 0xAB };
+ byte[] rx = new byte[1];
+ Task c1 = client.WriteAsync(one).AsTask();
+ Task s1 = server.ReadAsync(rx).AsTask();
+ await Task.WhenAll(c1, s1);
+ if (s1.Result != 1 || rx[0] != 0xAB) throw new IOException("sslstream ping failed");
+ rx[0] = 0;
+ byte[] back = new byte[1] { 0xCD };
+ Task s2 = server.WriteAsync(back).AsTask();
+ Task c2 = client.ReadAsync(rx).AsTask();
+ await Task.WhenAll(s2, c2);
+ if (c2.Result != 1 || rx[0] != 0xCD) throw new IOException("sslstream pong failed");
+ }
+
+ private static void DriveBufferedHandshakeAndPingPong(TlsBufferSession session, Socket socket)
+ {
+ DriveBufferedHandshake(session, socket);
+ if (!session.IsHandshakeComplete) throw new IOException("buffered handshake not complete before ping/pong");
+ byte[] netIn = ArrayPool.Shared.Rent(ScratchSize);
+ byte[] netOut = ArrayPool.Shared.Rent(ScratchSize);
+ byte[] plain = ArrayPool.Shared.Rent(ScratchSize);
+ try
+ {
+ // TLS 1.3: server emits NewSessionTicket records after Finished. They sit in
+ // OpenSSL's output BIO until we drain them; the client may also block waiting
+ // on them depending on the implementation.
+ DrainPending(session, socket, netOut);
+ // Read 1 plaintext byte from peer.
+ // Important: if the peer coalesced its client-Finished with the first app-data record
+ // into one TCP segment, the ping ciphertext was already absorbed by OpenSSL during
+ // the handshake. The first Decrypt call below is intentionally made with empty input
+ // so TlsSession can drain that buffered plaintext before we wait on the socket.
+ int inUsed = 0;
+ while (true)
+ {
+ TlsOperationStatus s = session.Read(netIn.AsSpan(0, inUsed), plain, out int consumed, out int produced);
+ if (consumed > 0)
+ {
+ if (consumed < inUsed) Buffer.BlockCopy(netIn, consumed, netIn, 0, inUsed - consumed);
+ inUsed -= consumed;
+ }
+ if (produced > 0)
+ {
+ if (plain[0] != 0xAB) throw new IOException("buffered ping mismatch");
+ break;
+ }
+ switch (s)
+ {
+ case TlsOperationStatus.NeedMoreData:
+ inUsed += NonBlockingReceiveSome(socket, netIn, inUsed);
+ continue;
+ case TlsOperationStatus.DestinationTooSmall:
+ DrainPending(session, socket, netOut);
+ continue;
+ case TlsOperationStatus.Closed:
+ throw new IOException("closed during ping read");
+ }
+ }
+ // Write 1 plaintext byte back
+ byte[] pong = new byte[1] { 0xCD };
+ int sent = 0;
+ while (sent < 1)
+ {
+ TlsOperationStatus s = session.Write(pong.AsSpan(sent), netOut, out int consumed, out int produced);
+ sent += consumed;
+ if (produced > 0) NonBlockingSendAll(socket, netOut, 0, produced);
+ if (s == TlsOperationStatus.DestinationTooSmall) DrainPending(session, socket, netOut);
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(plain);
+ ArrayPool.Shared.Return(netOut);
+ ArrayPool.Shared.Return(netIn);
+ }
+ }
+
+ private static void DriveFdHandshakeAndPingPong(TlsSocketSession session, Socket socket)
+ {
+ DriveFdHandshake(session, socket);
+ if (!session.IsHandshakeComplete) throw new IOException("fd handshake not complete before ping/pong");
+ // 1-byte read
+ byte[] rx = new byte[1];
+ while (true)
+ {
+ TlsOperationStatus s = session.Read(rx, out int produced);
+ if (produced == 1) { if (rx[0] != 0xAB) throw new IOException("fd ping mismatch"); break; }
+ switch (s)
+ {
+ case TlsOperationStatus.NeedMoreData: socket.Poll(-1, SelectMode.SelectRead); continue;
+ case TlsOperationStatus.DestinationTooSmall: socket.Poll(-1, SelectMode.SelectWrite); continue;
+ default: throw new IOException($"fd read status {s}");
+ }
+ }
+ // 1-byte write
+ byte[] tx = new byte[1] { 0xCD };
+ int sent = 0;
+ while (sent < 1)
+ {
+ TlsOperationStatus s = session.Write(tx.AsSpan(sent), out int consumed);
+ sent += consumed;
+ if (sent == 1) break;
+ switch (s)
+ {
+ case TlsOperationStatus.NeedMoreData: socket.Poll(-1, SelectMode.SelectRead); continue;
+ case TlsOperationStatus.DestinationTooSmall: socket.Poll(-1, SelectMode.SelectWrite); continue;
+ default: throw new IOException($"fd write status {s}");
+ }
+ }
+ }
+
+ private static void DriveBufferedHandshake(TlsBufferSession session, Socket socket)
+ {
+ byte[] netIn = ArrayPool.Shared.Rent(ScratchSize);
+ byte[] netOut = ArrayPool.Shared.Rent(ScratchSize);
+ int inUsed = 0;
+ try
+ {
+ while (!session.IsHandshakeComplete)
+ {
+ Probe.ProcessHandshakeCalls++;
+ TlsOperationStatus status = session.Handshake(
+ netIn.AsSpan(0, inUsed), netOut, out int consumed, out int produced);
+
+ if (consumed > 0)
+ {
+ if (consumed < inUsed)
+ {
+ Buffer.BlockCopy(netIn, consumed, netIn, 0, inUsed - consumed);
+ }
+ inUsed -= consumed;
+ }
+ if (produced > 0)
+ {
+ NonBlockingSendAll(socket, netOut, 0, produced);
+ }
+
+ switch (status)
+ {
+ case TlsOperationStatus.Complete:
+ continue;
+ case TlsOperationStatus.NeedsCertificateValidation:
+ session.AcceptWithDefaultValidation();
+ continue;
+ case TlsOperationStatus.DestinationTooSmall:
+ DrainPending(session, socket, netOut);
+ continue;
+ case TlsOperationStatus.NeedMoreData:
+ inUsed += NonBlockingReceiveSome(socket, netIn, inUsed);
+ continue;
+ case TlsOperationStatus.Closed:
+ throw new IOException("Closed in handshake.");
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(netOut);
+ ArrayPool.Shared.Return(netIn);
+ }
+ }
+
+ private static void DriveFdHandshake(TlsSocketSession session, Socket socket)
+ {
+ // fd-mode: OpenSSL drives the socket directly. Wait on socket readiness
+ // (not SpinWait) when WantRead/WantWrite surfaces.
+ while (true)
+ {
+ TlsOperationStatus s = session.Handshake();
+ switch (s)
+ {
+ case TlsOperationStatus.Complete:
+ return;
+ case TlsOperationStatus.NeedsCertificateValidation:
+ session.AcceptWithDefaultValidation();
+ continue;
+ case TlsOperationStatus.NeedMoreData:
+ Probe.PollRead++;
+ socket.Poll(-1, SelectMode.SelectRead);
+ continue;
+ case TlsOperationStatus.DestinationTooSmall:
+ Probe.PollWrite++;
+ socket.Poll(-1, SelectMode.SelectWrite);
+ continue;
+ default:
+ throw new IOException($"Unexpected handshake status: {s}");
+ }
+ }
+ }
+
+ // Deferred fd driver that resolves NeedsServerOptions via SetContext, using the
+ // bench's pre-warmed _ctxFd as the per-tenant context (SSL_CTX is already allocated
+ // and cert-installed on it, so the deferred session just adopts that CTX for the rest
+ // of the handshake).
+ private static void DriveFdDeferredHandshakeAndPingPong(TlsSocketSession session, Socket socket)
+ {
+ TlsHandshakeBench bench = s_current!;
+ while (true)
+ {
+ TlsOperationStatus s = session.Handshake();
+ if (s == TlsOperationStatus.Complete) break;
+ switch (s)
+ {
+ case TlsOperationStatus.NeedsTlsContext:
+ session.SetContext(bench._ctxFd);
+ continue;
+ case TlsOperationStatus.NeedsCertificateValidation:
+ session.AcceptWithDefaultValidation();
+ continue;
+ case TlsOperationStatus.NeedMoreData:
+ Probe.PollRead++;
+ socket.Poll(-1, SelectMode.SelectRead);
+ continue;
+ case TlsOperationStatus.DestinationTooSmall:
+ Probe.PollWrite++;
+ socket.Poll(-1, SelectMode.SelectWrite);
+ continue;
+ default:
+ throw new IOException($"Unexpected handshake status: {s}");
+ }
+ }
+ if (!session.IsHandshakeComplete) throw new IOException("fd deferred handshake not complete before ping/pong");
+
+ byte[] rx = new byte[1];
+ while (true)
+ {
+ TlsOperationStatus s = session.Read(rx, out int produced);
+ if (produced == 1) { if (rx[0] != 0xAB) throw new IOException("fd deferred ping mismatch"); break; }
+ switch (s)
+ {
+ case TlsOperationStatus.NeedMoreData: socket.Poll(-1, SelectMode.SelectRead); continue;
+ case TlsOperationStatus.DestinationTooSmall: socket.Poll(-1, SelectMode.SelectWrite); continue;
+ default: throw new IOException($"fd deferred read status {s}");
+ }
+ }
+ byte[] tx = new byte[1] { 0xCD };
+ int sent = 0;
+ while (sent < 1)
+ {
+ TlsOperationStatus s = session.Write(tx.AsSpan(sent), out int consumed);
+ sent += consumed;
+ if (sent == 1) break;
+ switch (s)
+ {
+ case TlsOperationStatus.NeedMoreData: socket.Poll(-1, SelectMode.SelectRead); continue;
+ case TlsOperationStatus.DestinationTooSmall: socket.Poll(-1, SelectMode.SelectWrite); continue;
+ default: throw new IOException($"fd deferred write status {s}");
+ }
+ }
+ }
+
+ private static void DrainPending(TlsBufferSession session, Socket socket, byte[] scratch)
+ {
+ while (session.HasPendingOutput)
+ {
+ Probe.DrainCalls++;
+ session.DrainPendingOutput(scratch, out int n);
+ if (n > 0)
+ {
+ NonBlockingSendAll(socket, scratch, 0, n);
+ }
+ }
+ }
+
+ private static void NonBlockingSendAll(Socket socket, byte[] buffer, int offset, int count)
+ {
+ while (count > 0)
+ {
+ try
+ {
+ int n = socket.Send(buffer, offset, count, SocketFlags.None);
+ Probe.Sends++; Probe.BytesSent += n;
+ offset += n;
+ count -= n;
+ }
+ catch (SocketException ex) when (ex.SocketErrorCode == SocketError.WouldBlock)
+ {
+ Probe.PollWrite++;
+ socket.Poll(-1, SelectMode.SelectWrite);
+ }
+ }
+ }
+
+ private static int NonBlockingReceiveSome(Socket socket, byte[] buffer, int offset)
+ {
+ while (true)
+ {
+ try
+ {
+ int n = socket.Receive(buffer, offset, buffer.Length - offset, SocketFlags.None);
+ Probe.Receives++; Probe.BytesReceived += n;
+ if (n == 0)
+ {
+ throw new IOException("Unexpected EOF.");
+ }
+ return n;
+ }
+ catch (SocketException ex) when (ex.SocketErrorCode == SocketError.WouldBlock)
+ {
+ Probe.PollRead++;
+ socket.Poll(-1, SelectMode.SelectRead);
+ }
+ }
+ }
+
+ private static X509Certificate2 CreateSelfSignedCert()
+ {
+ using RSA rsa = RSA.Create(2048);
+ var req = new CertificateRequest(
+ $"CN={ServerName}",
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ var san = new SubjectAlternativeNameBuilder();
+ san.AddDnsName(ServerName);
+ req.CertificateExtensions.Add(san.Build());
+ req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
+ req.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
+ req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
+ new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
+
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ using X509Certificate2 ephemeral = req.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(1));
+ // Round-trip to a PFX-backed cert so the private key is reliably available
+ // to both managed (Windows) and OpenSSL paths.
+ return X509CertificateLoader.LoadPkcs12(ephemeral.Export(X509ContentType.Pkcs12), null);
+ }
+}
+
+internal static class TraceHarness
+{
+ private static TlsBufferSession NewBufferSession(TlsContext ctx)
+ {
+ TlsBufferSession s = new TlsBufferSession();
+ s.SetContext(ctx);
+ return s;
+ }
+
+ private static TlsSocketSession NewSocketSession(TlsContext ctx, SafeSocketHandle handle)
+ {
+ TlsSocketSession s = new TlsSocketSession(handle);
+ s.SetContext(ctx);
+ return s;
+ }
+
+ public static async Task Run(int iterations, SslProtocols protocol, bool allowResume, string mode)
+ {
+ Console.WriteLine($"=== TRACE iterations={iterations} protocol={protocol} allowResume={allowResume} mode={mode} ===");
+
+ using X509Certificate2 cert = TraceCert();
+ string serverName = "tlsbench.local";
+ var serverOpts = new SslServerAuthenticationOptions
+ {
+ ServerCertificate = cert,
+ EnabledSslProtocols = protocol,
+ ClientCertificateRequired = false,
+ AllowTlsResume = allowResume,
+ };
+ var clientOpts = new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = protocol,
+ RemoteCertificateValidationCallback = static (_, _, _, _) => true,
+ AllowTlsResume = allowResume,
+ };
+ using TlsContext ctx = TlsContext.CreateServer(serverOpts);
+
+ var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ listener.Listen(8);
+ var ep = (IPEndPoint)listener.LocalEndPoint!;
+
+ for (int i = 0; i < iterations; i++)
+ {
+ Console.WriteLine($"\n--- iteration {i} ---");
+ var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+ Task accept = listener.AcceptAsync();
+ await client.ConnectAsync(ep);
+ Socket server = await accept;
+ server.NoDelay = true;
+ server.Blocking = false;
+
+ using TlsSession session = mode == "fd"
+ ? NewSocketSession(ctx, server.SafeHandle)
+ : NewBufferSession(ctx);
+
+ using var clientStream = new NetworkStream(client, ownsSocket: true);
+ using var sslClient = new SslStream(clientStream, leaveInnerStreamOpen: false);
+
+ Task c = Task.Run(async () =>
+ {
+ Console.WriteLine($"[i{i}][C] AuthenticateAsClient start");
+ await sslClient.AuthenticateAsClientAsync(clientOpts);
+ Console.WriteLine($"[i{i}][C] AuthenticateAsClient done; protocol={sslClient.SslProtocol} cipher={sslClient.NegotiatedCipherSuite}");
+ byte[] tx = new byte[] { 0xAB };
+ Console.WriteLine($"[i{i}][C] WriteAsync 1 byte (ping)");
+ await sslClient.WriteAsync(tx);
+ byte[] rx = new byte[1];
+ Console.WriteLine($"[i{i}][C] ReadAsync 1 byte (pong)");
+ int n = await sslClient.ReadAsync(rx);
+ Console.WriteLine($"[i{i}][C] ReadAsync returned n={n} val=0x{rx[0]:X}");
+ });
+
+ Task s = Task.Run(() =>
+ {
+ try
+ {
+ DriveTraced(session, server, i, mode);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[i{i}][S] EXCEPTION: {ex}");
+ throw;
+ }
+ });
+
+ await Task.WhenAll(c, s);
+ try { server.Shutdown(SocketShutdown.Both); } catch { }
+ server.Dispose();
+ Console.WriteLine($"[i{i}] iteration complete");
+ }
+ listener.Dispose();
+ }
+
+ private static void DriveTraced(TlsSession session, Socket socket, int iter, string mode)
+ {
+ if (mode == "fd")
+ {
+ DriveTracedFd((TlsSocketSession)session, socket, iter);
+ }
+ else
+ {
+ DriveTracedBuffered((TlsBufferSession)session, socket, iter);
+ }
+ }
+
+ private static void DriveTracedFd(TlsSocketSession session, Socket socket, int iter)
+ {
+ Console.WriteLine($"[i{iter}][S] fd handshake start");
+ while (true)
+ {
+ TlsOperationStatus s = session.Handshake();
+ Console.WriteLine($"[i{iter}][S] Handshake -> {s} complete={session.IsHandshakeComplete}");
+ if (s == TlsOperationStatus.Complete) break;
+ if (s == TlsOperationStatus.NeedsCertificateValidation) { session.AcceptWithDefaultValidation(); continue; }
+ if (s == TlsOperationStatus.NeedMoreData) { socket.Poll(-1, SelectMode.SelectRead); continue; }
+ if (s == TlsOperationStatus.DestinationTooSmall) { socket.Poll(-1, SelectMode.SelectWrite); continue; }
+ throw new IOException($"unexpected {s}");
+ }
+
+ Console.WriteLine($"[i{iter}][S] reading 1-byte ping");
+ byte[] rx = new byte[1];
+ while (true)
+ {
+ TlsOperationStatus s = session.Read(rx, out int produced);
+ Console.WriteLine($"[i{iter}][S] Read -> {s} produced={produced}");
+ if (produced == 1) break;
+ if (s == TlsOperationStatus.NeedMoreData) { socket.Poll(-1, SelectMode.SelectRead); continue; }
+ if (s == TlsOperationStatus.DestinationTooSmall) { socket.Poll(-1, SelectMode.SelectWrite); continue; }
+ throw new IOException($"read {s}");
+ }
+
+ Console.WriteLine($"[i{iter}][S] writing 1-byte pong");
+ byte[] tx = new byte[] { 0xCD };
+ int written = 0;
+ while (written < 1)
+ {
+ TlsOperationStatus s = session.Write(tx.AsSpan(written), out int consumed);
+ written += consumed;
+ Console.WriteLine($"[i{iter}][S] Write -> {s} consumed={consumed}");
+ if (written == 1) break;
+ if (s == TlsOperationStatus.DestinationTooSmall) { socket.Poll(-1, SelectMode.SelectWrite); continue; }
+ if (s == TlsOperationStatus.NeedMoreData) { socket.Poll(-1, SelectMode.SelectRead); continue; }
+ throw new IOException($"write {s}");
+ }
+ Console.WriteLine($"[i{iter}][S] done");
+ }
+
+ private static void DriveTracedBuffered(TlsBufferSession session, Socket socket, int iter)
+ {
+ const int ScratchSize = 32 * 1024;
+ byte[] netIn = new byte[ScratchSize];
+ byte[] netOut = new byte[ScratchSize];
+ int inUsed = 0;
+
+ Console.WriteLine($"[i{iter}][S] buffered handshake start");
+ while (!session.IsHandshakeComplete)
+ {
+ TlsOperationStatus status = session.Handshake(netIn.AsSpan(0, inUsed), netOut, out int consumed, out int produced);
+ Console.WriteLine($"[i{iter}][S] ProcessHandshake in={inUsed} consumed={consumed} produced={produced} -> {status} complete={session.IsHandshakeComplete}");
+ if (consumed > 0)
+ {
+ if (consumed < inUsed) Buffer.BlockCopy(netIn, consumed, netIn, 0, inUsed - consumed);
+ inUsed -= consumed;
+ }
+ if (produced > 0)
+ {
+ int sent = TraceSend(socket, netOut, 0, produced, iter, "hs");
+ Console.WriteLine($"[i{iter}][S] wrote {sent} bytes to socket");
+ }
+ switch (status)
+ {
+ case TlsOperationStatus.Complete: continue;
+ case TlsOperationStatus.NeedsCertificateValidation: session.AcceptWithDefaultValidation(); continue;
+ case TlsOperationStatus.DestinationTooSmall:
+ Console.WriteLine($"[i{iter}][S] draining pending");
+ DrainTraced(session, socket, netOut, iter);
+ continue;
+ case TlsOperationStatus.NeedMoreData:
+ inUsed += TraceRecv(socket, netIn, inUsed, iter, "hs");
+ continue;
+ case TlsOperationStatus.Closed:
+ throw new IOException("closed in handshake");
+ }
+ }
+ Console.WriteLine($"[i{iter}][S] handshake complete; post-handshake drain");
+ DrainTraced(session, socket, netOut, iter);
+
+ Console.WriteLine($"[i{iter}][S] reading 1-byte ping");
+ byte[] plain = new byte[1024];
+ while (true)
+ {
+ TlsOperationStatus s = session.Read(netIn.AsSpan(0, inUsed), plain, out int consumed, out int produced);
+ Console.WriteLine($"[i{iter}][S] Decrypt in={inUsed} consumed={consumed} produced={produced} -> {s}");
+ if (consumed > 0)
+ {
+ if (consumed < inUsed) Buffer.BlockCopy(netIn, consumed, netIn, 0, inUsed - consumed);
+ inUsed -= consumed;
+ }
+ if (produced > 0) break;
+ switch (s)
+ {
+ case TlsOperationStatus.NeedMoreData:
+ inUsed += TraceRecv(socket, netIn, inUsed, iter, "ping");
+ continue;
+ case TlsOperationStatus.DestinationTooSmall:
+ DrainTraced(session, socket, netOut, iter);
+ continue;
+ case TlsOperationStatus.Closed:
+ throw new IOException("closed");
+ }
+ }
+
+ Console.WriteLine($"[i{iter}][S] writing 1-byte pong");
+ byte[] tx = new byte[] { 0xCD };
+ int wrote = 0;
+ while (wrote < 1)
+ {
+ TlsOperationStatus s = session.Write(tx.AsSpan(wrote), netOut, out int consumed, out int produced);
+ wrote += consumed;
+ Console.WriteLine($"[i{iter}][S] Encrypt -> {s} consumed={consumed} produced={produced}");
+ if (produced > 0) TraceSend(socket, netOut, 0, produced, iter, "pong");
+ if (s == TlsOperationStatus.DestinationTooSmall) DrainTraced(session, socket, netOut, iter);
+ }
+ Console.WriteLine($"[i{iter}][S] done");
+ }
+
+ private static void DrainTraced(TlsBufferSession session, Socket socket, byte[] scratch, int iter)
+ {
+ while (session.HasPendingOutput)
+ {
+ session.DrainPendingOutput(scratch, out int n);
+ Console.WriteLine($"[i{iter}][S] DrainPendingOutput n={n}");
+ if (n > 0) TraceSend(socket, scratch, 0, n, iter, "drain");
+ }
+ }
+
+ private static int TraceSend(Socket s, byte[] buf, int off, int count, int iter, string tag)
+ {
+ int total = 0;
+ while (count > 0)
+ {
+ try
+ {
+ int n = s.Send(buf, off, count, SocketFlags.None);
+ Console.WriteLine($"[i{iter}][S] send/{tag} n={n}");
+ off += n; count -= n; total += n;
+ }
+ catch (SocketException e) when (e.SocketErrorCode == SocketError.WouldBlock)
+ {
+ s.Poll(-1, SelectMode.SelectWrite);
+ }
+ }
+ return total;
+ }
+
+ private static int TraceRecv(Socket s, byte[] buf, int off, int iter, string tag)
+ {
+ while (true)
+ {
+ try
+ {
+ int n = s.Receive(buf, off, buf.Length - off, SocketFlags.None);
+ Console.WriteLine($"[i{iter}][S] recv/{tag} n={n}");
+ if (n == 0) throw new IOException("EOF");
+ return n;
+ }
+ catch (SocketException e) when (e.SocketErrorCode == SocketError.WouldBlock)
+ {
+ s.Poll(-1, SelectMode.SelectRead);
+ }
+ }
+ }
+
+ private static X509Certificate2 TraceCert()
+ {
+ using RSA rsa = RSA.Create(2048);
+ var req = new CertificateRequest("CN=tlsbench.local", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ var san = new SubjectAlternativeNameBuilder();
+ san.AddDnsName("tlsbench.local");
+ req.CertificateExtensions.Add(san.Build());
+ req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
+ req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
+ req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ using X509Certificate2 ephem = req.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(1));
+ return X509CertificateLoader.LoadPkcs12(ephem.Export(X509ContentType.Pkcs12), null);
+ }
+}
diff --git a/src/libraries/System.Net.Security/perf/TlsHandshakeBench/README.md b/src/libraries/System.Net.Security/perf/TlsHandshakeBench/README.md
new file mode 100644
index 00000000000000..77a892bd71261c
--- /dev/null
+++ b/src/libraries/System.Net.Security/perf/TlsHandshakeBench/README.md
@@ -0,0 +1,63 @@
+# TlsHandshakeBench
+
+Ad-hoc handshake benchmark comparing `SslStream` (baseline) against `TlsSession` in
+both buffered and fd-bound modes. Runs against a real loopback TCP socket so I/O
+transitions are exercised.
+
+> This project is **temporary** — eventually it will move to `dotnet/performance`.
+> It is not part of the regular libraries build (not referenced from any solution
+> or test list).
+
+## Run
+
+The benchmark depends on the live-built `System.Net.Security` (TlsSession is not
+in any shipped runtime), so it must run against the in-tree testhost rather than
+the SDK's bundled runtime.
+
+```bash
+# 1) Baseline build (required once; populates a release-ish runtime layout).
+./build.sh clr+libs+libs.pretest -rc release
+
+# 2) Build the benchmark.
+./dotnet.sh build -c Release \
+ src/libraries/System.Net.Security/perf/TlsHandshakeBench/TlsHandshakeBench.csproj
+
+# 3) Run with the local testhost.
+artifacts/bin/testhost/net11.0-linux-Release-x64/dotnet \
+ artifacts/bin/TlsHandshakeBench/Release/net11.0/TlsHandshakeBench.dll --filter '*'
+```
+
+If the release testhost layout is incomplete (missing `dotnet` host binary or
+native libs), fall back to the debug testhost — relative comparisons remain
+useful even though absolute numbers will be slower:
+
+```bash
+artifacts/bin/testhost/net11.0-linux-Debug-x64/dotnet \
+ artifacts/bin/TlsHandshakeBench/Release/net11.0/TlsHandshakeBench.dll --filter '*'
+```
+
+The benchmark uses `InProcessEmitToolchain` so no child processes are spawned —
+BenchmarkDotNet doesn't recognize the `net11.0` runtime moniker yet, and
+in-process is fine for I/O-dominated workloads.
+
+To run a single mode:
+
+```bash
+... TlsHandshakeBench.dll --filter '*TlsSession_Fd*'
+```
+
+## What's measured
+
+Each benchmark performs one full TLS handshake over a fresh loopback TCP connection
+(connect + accept + handshake + dispose). Three engines:
+
+- **SslStream_Server** — baseline; `SslStream` on both ends.
+- **TlsSession_Buffered_Server** — `TlsSession` on the server, driving I/O through
+ the managed `ProcessHandshake` / `DrainPendingOutput` buffer loop.
+- **TlsSession_Fd_Server** — `TlsSession.Create(ctx, socketHandle)`; OpenSSL drives
+ ciphertext I/O directly via `SSL_set_fd`. (Linux/FreeBSD only.)
+
+Parameterized over `Tls12` and `Tls13`.
+
+`TlsContext` is allocated once in `[GlobalSetup]` and reused across iterations so
+per-session cost is measured (not SSL_CTX allocation).
diff --git a/src/libraries/System.Net.Security/perf/TlsHandshakeBench/TlsHandshakeBench.csproj b/src/libraries/System.Net.Security/perf/TlsHandshakeBench/TlsHandshakeBench.csproj
new file mode 100644
index 00000000000000..ada636661dbe42
--- /dev/null
+++ b/src/libraries/System.Net.Security/perf/TlsHandshakeBench/TlsHandshakeBench.csproj
@@ -0,0 +1,36 @@
+
+
+ Exe
+ $(NetCoreAppCurrent)
+ enable
+ latest
+ true
+ true
+
+ https://api.nuget.org/v3/index.json
+
+ false
+ false
+ true
+
+ false
+ false
+ false
+ $(NoWarn);CA2007;CA2025;CS3016;CS1591;SYSLIB5007
+ false
+ false
+ false
+
+
+
+
+
+ $(MicrosoftNetCoreAppRefPackRefDir)netstandard.dll
+
+
+
+
+
+
diff --git a/src/libraries/System.Net.Security/ref/System.Net.Security.cs b/src/libraries/System.Net.Security/ref/System.Net.Security.cs
index 08aaf8df3ed2cc..2fb982bb771cf2 100644
--- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs
+++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs
@@ -687,6 +687,72 @@ public enum TlsCipherSuite : ushort
TLS_ECDHE_PSK_WITH_AES_128_CCM_8_SHA256 = (ushort)53251,
TLS_ECDHE_PSK_WITH_AES_128_CCM_SHA256 = (ushort)53253,
}
+ [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")]
+ public enum TlsOperationStatus
+ {
+ Complete = 0,
+ DestinationTooSmall = 1,
+ NeedMoreData = 2,
+ Closed = 3,
+ CertificateRequested = 4,
+ NeedsCertificateValidation = 5,
+ NeedsTlsContext = 6,
+ }
+ [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")]
+ public sealed partial class TlsContext : System.IDisposable
+ {
+ internal TlsContext() { }
+ public static System.Net.Security.TlsContext CreateServer(System.Net.Security.SslServerAuthenticationOptions options) { throw null; }
+ public static System.Net.Security.TlsContext CreateClient(System.Net.Security.SslClientAuthenticationOptions options) { throw null; }
+ public void Dispose() { }
+ }
+ [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")]
+ public abstract partial class TlsSession : System.IDisposable
+ {
+ private protected TlsSession() { }
+ public bool IsHandshakeComplete { get { throw null; } }
+ public bool HasPendingOutput { get { throw null; } }
+ public string? TargetHostName { get { throw null; } set { } }
+ public System.Net.Security.SslClientHelloInfo? ClientHelloInfo { get { throw null; } }
+ public int GetClientHelloLength() { throw null; }
+ public bool TryGetClientHelloBytes(System.Span destination, out int bytesWritten) { throw null; }
+ public System.Security.Authentication.SslProtocols NegotiatedProtocol { get { throw null; } }
+ [System.CLSCompliantAttribute(false)]
+ public System.Net.Security.TlsCipherSuite NegotiatedCipherSuite { get { throw null; } }
+ public System.Net.Security.SslApplicationProtocol NegotiatedApplicationProtocol { get { throw null; } }
+ public System.Security.Cryptography.X509Certificates.X509Certificate2? GetRemoteCertificate() { throw null; }
+ public System.Security.Cryptography.X509Certificates.X509Certificate2Collection? GetRemoteCertificates() { throw null; }
+ public System.Net.Security.SslPolicyErrors AcceptWithDefaultValidation() { throw null; }
+ public void SetRemoteCertificateValidationResult(System.Net.Security.SslPolicyErrors errors) { }
+ public void SetContext(System.Net.Security.TlsContext context) { }
+ public void SetClientCertificateContext(System.Net.Security.SslStreamCertificateContext? context) { }
+ public System.Collections.Generic.IReadOnlyList? GetAcceptableIssuers() { throw null; }
+ public System.Security.Cryptography.X509Certificates.X509Certificate2? LocalCertificate { get { throw null; } }
+ public System.Security.Authentication.ExtendedProtection.ChannelBinding? GetChannelBinding(System.Security.Authentication.ExtendedProtection.ChannelBindingKind kind) { throw null; }
+ public void Dispose() { }
+ }
+ [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")]
+ public sealed partial class TlsBufferSession : System.Net.Security.TlsSession
+ {
+ public TlsBufferSession() { }
+ public System.Net.Security.TlsOperationStatus Handshake(System.ReadOnlySpan source, System.Span destination, out int bytesConsumed, out int bytesWritten) { throw null; }
+ public System.Net.Security.TlsOperationStatus Write(System.ReadOnlySpan source, System.Span destination, out int bytesConsumed, out int bytesWritten) { throw null; }
+ public System.Net.Security.TlsOperationStatus Read(System.ReadOnlySpan source, System.Span destination, out int bytesConsumed, out int bytesWritten) { throw null; }
+ public System.Net.Security.TlsOperationStatus Shutdown(System.Span ciphertext, out int bytesWritten) { throw null; }
+ public System.Net.Security.TlsOperationStatus DrainPendingOutput(System.Span ciphertext, out int bytesWritten) { throw null; }
+ public System.Net.Security.TlsOperationStatus RequestClientCertificate(System.Span ciphertext, out int bytesWritten) { throw null; }
+ }
+ [System.Diagnostics.CodeAnalysis.ExperimentalAttribute("SYSLIB5007", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")]
+ public sealed partial class TlsSocketSession : System.Net.Security.TlsSession
+ {
+ public TlsSocketSession(System.Net.Sockets.SafeSocketHandle socket) { }
+ public System.Net.Sockets.SafeSocketHandle Socket { get { throw null; } }
+ public System.Net.Security.TlsOperationStatus Handshake() { throw null; }
+ public System.Net.Security.TlsOperationStatus Read(System.Span buffer, out int bytesRead) { throw null; }
+ public System.Net.Security.TlsOperationStatus Write(System.ReadOnlySpan buffer, out int bytesWritten) { throw null; }
+ public System.Net.Security.TlsOperationStatus Shutdown() { throw null; }
+ public System.Net.Security.TlsOperationStatus RequestClientCertificate() { throw null; }
+ }
}
namespace System.Security.Authentication
{
diff --git a/src/libraries/System.Net.Security/ref/System.Net.Security.csproj b/src/libraries/System.Net.Security/ref/System.Net.Security.csproj
index 0eb7f5d81fe3cc..837dee4d0047a3 100644
--- a/src/libraries/System.Net.Security/ref/System.Net.Security.csproj
+++ b/src/libraries/System.Net.Security/ref/System.Net.Security.csproj
@@ -12,6 +12,7 @@
+
diff --git a/src/libraries/System.Net.Security/src/Resources/Strings.resx b/src/libraries/System.Net.Security/src/Resources/Strings.resx
index 3eea456f1a238f..bb56bce2aa1765 100644
--- a/src/libraries/System.Net.Security/src/Resources/Strings.resx
+++ b/src/libraries/System.Net.Security/src/Resources/Strings.resx
@@ -1,16 +1,16 @@
-
@@ -338,6 +338,9 @@
SSL Read BIO failed with OpenSSL error - {0}.
+
+ The session has no TlsContext assigned. Call SetContext with a client or server TlsContext before invoking this operation.
+
Using SSL certificate failed with OpenSSL error - {0}.
@@ -431,6 +434,9 @@
Client stream needs to be drained before renegotiation.
+
+ TLS renegotiation is not supported on this platform.
+
Setting an SNI hostname is not supported on this API level.
diff --git a/src/libraries/System.Net.Security/src/System.Net.Security.csproj b/src/libraries/System.Net.Security/src/System.Net.Security.csproj
index 577bcccf801c5d..ca3c706d462e23 100644
--- a/src/libraries/System.Net.Security/src/System.Net.Security.csproj
+++ b/src/libraries/System.Net.Security/src/System.Net.Security.csproj
@@ -6,6 +6,7 @@
$(DefineConstants);PRODUCTfalse
+ $(NoWarn);SYSLIB5007
@@ -28,6 +29,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs b/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs
index 369ece8ca60d0e..7e0f84a71cc4c8 100644
--- a/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs
@@ -16,6 +16,13 @@ internal static bool DisableTlsResume
get => GetCachedSwitchValue("System.Net.Security.DisableTlsResume", "DOTNET_SYSTEM_NET_SECURITY_DISABLETLSRESUME", ref s_disableTlsResume);
}
+ private static int s_captureClientHello;
+ internal static bool CaptureClientHello
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => GetCachedSwitchValue("System.Net.Security.CaptureClientHello", "DOTNET_SYSTEM_NET_SECURITY_CAPTURECLIENTHELLO", ref s_captureClientHello, defaultValue: true);
+ }
+
private static int s_enableServerAiaDownloads;
internal static bool EnableServerAiaDownloads
{
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/NetEventSource.Security.cs b/src/libraries/System.Net.Security/src/System/Net/Security/NetEventSource.Security.cs
index c4790df836c8fa..d4f7ade966b162 100644
--- a/src/libraries/System.Net.Security/src/System/Net/Security/NetEventSource.Security.cs
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/NetEventSource.Security.cs
@@ -208,32 +208,32 @@ public void SspiSelectedCipherSuite(
#pragma warning restore SYSLIB0058 // Use NegotiatedCipherSuite.
[NonEvent]
- public void RemoteCertificateError(SslStream SslStream, string message) =>
- RemoteCertificateError(GetHashCode(SslStream), message);
+ public void RemoteCertificateError(object sender, string message) =>
+ RemoteCertificateError(GetHashCode(sender), message);
[Event(RemoteCertificateErrorId, Level = EventLevel.Verbose)]
private void RemoteCertificateError(int sslStreamHash, string message) =>
WriteEvent(RemoteCertificateErrorId, sslStreamHash, message);
[NonEvent]
- public void RemoteCertDeclaredValid(SslStream SslStream) =>
- RemoteCertDeclaredValid(GetHashCode(SslStream));
+ public void RemoteCertDeclaredValid(object sender) =>
+ RemoteCertDeclaredValid(GetHashCode(sender));
[Event(RemoteVertificateValidId, Level = EventLevel.Verbose)]
private void RemoteCertDeclaredValid(int sslStreamHash) =>
WriteEvent(RemoteVertificateValidId, sslStreamHash);
[NonEvent]
- public void RemoteCertHasNoErrors(SslStream SslStream) =>
- RemoteCertHasNoErrors(GetHashCode(SslStream));
+ public void RemoteCertHasNoErrors(object sender) =>
+ RemoteCertHasNoErrors(GetHashCode(sender));
[Event(RemoteCertificateSuccessId, Level = EventLevel.Verbose)]
private void RemoteCertHasNoErrors(int sslStreamHash) =>
WriteEvent(RemoteCertificateSuccessId, sslStreamHash);
[NonEvent]
- public void RemoteCertUserDeclaredInvalid(SslStream SslStream) =>
- RemoteCertUserDeclaredInvalid(GetHashCode(SslStream));
+ public void RemoteCertUserDeclaredInvalid(object sender) =>
+ RemoteCertUserDeclaredInvalid(GetHashCode(sender));
[Event(RemoteCertificateInvalidId, Level = EventLevel.Verbose)]
private void RemoteCertUserDeclaredInvalid(int sslStreamHash) =>
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/Pal.OSX/SafeDeleteNwContext.cs b/src/libraries/System.Net.Security/src/System/Net/Security/Pal.OSX/SafeDeleteNwContext.cs
index 88c5e5346c8a8a..37bda97f11cc93 100644
--- a/src/libraries/System.Net.Security/src/System/Net/Security/Pal.OSX/SafeDeleteNwContext.cs
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/Pal.OSX/SafeDeleteNwContext.cs
@@ -83,6 +83,9 @@ internal sealed class SafeDeleteNwContext : SafeDeleteContext
private bool _disposed;
private int _challengeCallbackCompleted; // 0 = not called, 1 = called
private IntPtr _selectedClientCertificate; // Cached result from challenge callback
+ // True when the transport reported EOF before NW signalled a clean close_notify;
+ // any pending or future app receive should surface as IOException(net_io_eof) rather than 0.
+ private volatile bool _transportEofUnclean;
private ResettableValueTaskSource _appWriteTcs = new ResettableValueTaskSource()
{
@@ -100,7 +103,6 @@ internal sealed class SafeDeleteNwContext : SafeDeleteContext
public SafeDeleteNwContext(SslStream stream) : base(IntPtr.Zero)
{
_sslStream = stream;
- ValidateSslAuthenticationOptions(SslAuthenticationOptions);
_thisHandle = GCHandle.Alloc(this, GCHandleType.Normal);
ConnectionHandle = CreateConnectionHandle(SslAuthenticationOptions, _thisHandle);
@@ -148,6 +150,17 @@ public SafeDeleteNwContext(SslStream stream) : base(IntPtr.Zero)
// EOF reached, signal completion
_transportReadTcs.TrySetResult(final: true);
+ // If NW hasn't already signalled a clean TLS close, treat this as
+ // an unclean EOF (possibly mid-frame) and fault any pending app
+ // receive directly. NW's pending nw_connection_receive may never
+ // complete once the framer has buffered a partial TLS record.
+ if (!_connectionClosedTcs.Task.IsCompleted)
+ {
+ _transportEofUnclean = true;
+ _appReceiveBufferTcs.TrySetException(ExceptionDispatchInfo.SetCurrentStackTrace(new IOException(SR.net_io_eof)));
+ _handshakeCompletionSource.TrySetException(ExceptionDispatchInfo.SetCurrentStackTrace(new IOException(SR.net_io_eof)));
+ }
+
// TODO: can this race with actual handshake completion?
Interop.NetworkFramework.Tls.NwConnectionCancel(ConnectionHandle);
break;
@@ -165,9 +178,13 @@ public SafeDeleteNwContext(SslStream stream) : base(IntPtr.Zero)
}
catch (Exception ex)
{
- // Propagate transport stream exceptions to the handshake
+ // Propagate transport stream exceptions to the handshake / pending write.
+ // Swallow on this task so a fire-and-forget Dispose can't surface an
+ // UnobservedTaskException; the exception is observed through the TCSes.
_handshakeCompletionSource.TrySetException(ex);
_currentWriteCompletionSource?.TrySetException(ex);
+ _appReceiveBufferTcs.TrySetException(ex);
+ if (NetEventSource.Log.IsEnabled()) NetEventSource.Error(this, $"Transport read loop terminated: {ex}");
}
}, cancellationToken);
@@ -318,6 +335,13 @@ static unsafe void CompletionCallback(IntPtr context, Interop.NetworkFramework.N
if (error->ErrorDomain == (int)Interop.NetworkFramework.NetworkFrameworkErrorDomain.POSIX &&
error->ErrorCode == (int)Interop.NetworkFramework.NWErrorDomainPOSIX.OperationCanceled)
{
+ if (thisContext._transportEofUnclean)
+ {
+ if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(thisContext, "Connection read cancelled after unclean transport EOF");
+ thisContext._appReceiveBufferTcs.TrySetException(ExceptionDispatchInfo.SetCurrentStackTrace(new IOException(SR.net_io_eof)));
+ return;
+ }
+
// We cancelled the connection, so this is expected as pending read will be cancelled.
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(thisContext, "Connection read cancelled, no data to process");
thisContext._appReceiveBufferTcs.TrySetResult();
@@ -359,23 +383,6 @@ private static bool CheckNetworkFrameworkAvailability()
}
}
- private static void ValidateSslAuthenticationOptions(SslAuthenticationOptions options)
- {
- switch (options.EncryptionPolicy)
- {
- case EncryptionPolicy.RequireEncryption:
-#pragma warning disable SYSLIB0040 // NoEncryption and AllowNoEncryption are obsolete
- case EncryptionPolicy.AllowNoEncryption:
- // SecureTransport doesn't allow TLS_NULL_NULL_WITH_NULL, but
- // since AllowNoEncryption intersect OS-supported isn't nothing,
- // let it pass.
- break;
-#pragma warning restore SYSLIB0040
- default:
- throw new PlatformNotSupportedException(SR.Format(SR.net_encryptionpolicy_notsupported, options.EncryptionPolicy));
- }
- }
-
private static SafeNwHandle CreateConnectionHandle(SslAuthenticationOptions options, GCHandle thisHandle)
{
int alpnLength = GetAlpnProtocolListSerializedLength(options.ApplicationProtocols);
@@ -409,12 +416,19 @@ private static SafeNwHandle CreateConnectionHandle(SslAuthenticationOptions opti
string idnHost = TargetHostNameHelper.NormalizeHostName(options.TargetHost);
+ // For server-side TLS, hand the SecIdentityRef of the server certificate down to the
+ // native layer. On macOS, X509Certificate2.Handle returns the SecIdentityRef when the
+ // certificate has an associated private key.
+ IntPtr serverIdentity = options.IsServer
+ ? options.CertificateContext?.TargetCertificate.Handle ?? IntPtr.Zero
+ : IntPtr.Zero;
+
unsafe
{
fixed (byte* alpnPtr = alpn)
fixed (uint* ciphersPtr = ciphers)
{
- return Interop.NetworkFramework.Tls.NwConnectionCreate(options.IsServer, GCHandle.ToIntPtr(thisHandle), idnHost, alpnPtr, alpnLength, minProtocol, maxProtocol, ciphersPtr, ciphers.Length);
+ return Interop.NetworkFramework.Tls.NwConnectionCreate(options.IsServer, GCHandle.ToIntPtr(thisHandle), idnHost, alpnPtr, alpnLength, minProtocol, maxProtocol, ciphersPtr, ciphers.Length, serverIdentity);
}
}
}
@@ -503,14 +517,25 @@ protected override void Dispose(bool disposing)
Shutdown();
- // Wait for the transport read task to complete
+ // Bounded wait: the task usually exits within a few ms after Shutdown()
+ // (NwConnectionCancel unblocks any framer await). If the loop is parked
+ // in TransportStream.ReadAsync on a stream that ignores cancellation,
+ // it will unwind once the inner stream is closed (base.Dispose below
+ // when leaveInnerStreamOpen=false, or by the caller). The task swallows
+ // all exceptions internally so it cannot raise UnobservedTaskException.
+ bool transportCompleted = true;
if (_transportReadTask is Task transportTask)
{
- // Ignore exceptions from the transport task
- transportTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing).GetAwaiter().GetResult();
+ try
+ {
+ transportCompleted = transportTask.Wait(TimeSpan.FromMilliseconds(250));
+ }
+ catch
+ {
+ transportCompleted = transportTask.IsCompleted;
+ }
}
-
// Wait for any pending app receive tasks so that we may safely dispose the app receive buffer.
if (_pendingAppReceiveBufferFillTask is Task t)
{
@@ -536,13 +561,35 @@ protected override void Dispose(bool disposing)
writeCompletion.TrySetException(new ObjectDisposedException(nameof(SafeDeleteNwContext)));
}
- ConnectionHandle.Dispose();
+ ConnectionHandle?.Dispose();
_framerHandle?.Dispose();
_peerCertChainHandle?.Dispose();
_shutdownCts?.Dispose();
- // now that we know all callbacks are done, we can free the handle
- _thisHandle.Free();
+ // The GCHandle is the resolution target for native callbacks (framer
+ // completion, status updates). Only free it once we know the transport
+ // read loop has stopped issuing native calls. If it did not finish in
+ // the bounded wait above, defer the free via a continuation (same
+ // pattern used by SocketsHttpHandler for orphaned tasks).
+ if (transportCompleted)
+ {
+ if (_thisHandle.IsAllocated)
+ {
+ _thisHandle.Free();
+ }
+ }
+ else
+ {
+ GCHandle handleToFree = _thisHandle;
+ _transportReadTask!.ContinueWith(static (_, state) =>
+ {
+ GCHandle h = (GCHandle)state!;
+ if (h.IsAllocated)
+ {
+ h.Free();
+ }
+ }, handleToFree, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
+ }
}
base.Dispose(disposing);
}
@@ -660,7 +707,7 @@ static unsafe void CompletionCallback(IntPtr context, Interop.NetworkFramework.N
}
}
- public override bool IsInvalid => ConnectionHandle.IsInvalid || (_framerHandle?.IsInvalid ?? true);
+ public override bool IsInvalid => ConnectionHandle is null || ConnectionHandle.IsInvalid || (_framerHandle?.IsInvalid ?? true);
[UnmanagedCallersOnly]
private static unsafe void StatusUpdateCallback(IntPtr thisHandle, NetworkFrameworkStatusUpdates statusUpdate, IntPtr data, IntPtr data2, Interop.NetworkFramework.NetworkFrameworkError* error)
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslAuthenticationOptions.OpenSsl.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslAuthenticationOptions.OpenSsl.cs
new file mode 100644
index 00000000000000..01173e167b9b27
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslAuthenticationOptions.OpenSsl.cs
@@ -0,0 +1,44 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Win32.SafeHandles;
+using System.Net.Sockets;
+
+namespace System.Net.Security
+{
+ internal sealed partial class SslAuthenticationOptions
+ {
+ // Pre-allocated SSL_CTX owned by a TlsContext and shared by every TlsSession
+ // it produces. When set, Interop.OpenSsl.AllocateSslHandle uses this handle
+ // directly and bypasses the global SslContextCacheKey lookup. Unset for the
+ // legacy SslStream path, which keeps using the static cache.
+ //
+ // Lifetime: owned by TlsContext (not this options bag); set in TlsContext's
+ // CreateSessionOptions and read by AllocateSslHandle. Not copied by Clone()
+ // \u2014 each per-session clone gets the field re-stamped by CreateSessionOptions.
+ internal SafeSslContextHandle? PreallocatedSslContext { get; set; }
+
+ // Socket handle to bind to the SSL object via SSL_set_fd. When set,
+ // SafeSslHandle.Create skips the ManagedSpanBio installation and OpenSSL
+ // reads/writes the socket directly. Used by TlsSession's socket-bound mode
+ // (Create(TlsContext, SafeSocketHandle)).
+ internal SafeSocketHandle? SocketHandle { get; set; }
+
+ // ClientHello bytes already consumed from SocketHandle by the managed
+ // pre-fetch loop used to surface SNI to a deferred ServerOptionsSelectionCallback.
+ // When both SocketHandle and this are set, SafeSslHandle.Create installs a
+ // socket-replay BIO on the SSL* instead of SSL_set_fd so those bytes are
+ // fed back to OpenSSL's handshake state machine before further recv().
+ // Only meaningful when SocketHandle is also set; cleared once transferred
+ // to the BIO (the BIO holds its own copy).
+ internal byte[]? ReplayPrefix { get; set; }
+
+ // Preferred over ReplayPrefix: a socket-replay BIO already bound to the
+ // fd and pre-populated (via BioReadTlsFrame) with the ClientHello record.
+ // SafeSslHandle.Create adopts it as the SSL's read BIO — no separate
+ // managed pre-fetch buffer, no byte[] copy, no second BioNewSocketReplay
+ // allocation. Ownership transfers to the SSL* at Create time; the field
+ // is cleared afterwards.
+ internal SafeBioHandle? PreallocatedReadBio { get; set; }
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslAuthenticationOptions.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslAuthenticationOptions.cs
index 6aaa1932ea76e2..d8aca585cbdc8d 100644
--- a/src/libraries/System.Net.Security/src/System/Net/Security/SslAuthenticationOptions.cs
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslAuthenticationOptions.cs
@@ -9,7 +9,7 @@
namespace System.Net.Security
{
- internal sealed class SslAuthenticationOptions : IDisposable
+ internal sealed partial class SslAuthenticationOptions : IDisposable
{
internal const X509RevocationMode DefaultRevocationMode = X509RevocationMode.NoCheck;
@@ -193,16 +193,85 @@ internal void SetCertificateContextFromCert(X509Certificate2 certificate, bool?
OwnsCertificateContext = true;
}
+ // Shallow copy of the configuration carried by this bag. Per-handle/per-stream
+ // state (SafeSslHandle, SslStream, RemoteCertificateValidator) is intentionally
+ // not propagated, and the clone does not take ownership of CertificateContext
+ // even if the source did.
+ internal SslAuthenticationOptions Clone()
+ {
+ SslAuthenticationOptions copy = new SslAuthenticationOptions
+ {
+ AllowRenegotiation = AllowRenegotiation,
+ TargetHost = TargetHost,
+ ClientCertificates = ClientCertificates,
+ ApplicationProtocols = ApplicationProtocols,
+ IsServer = IsServer,
+ CertificateContext = CertificateContext,
+ OwnsCertificateContext = false,
+ EnabledSslProtocols = EnabledSslProtocols,
+ CertificateRevocationCheckMode = CertificateRevocationCheckMode,
+ EncryptionPolicy = EncryptionPolicy,
+ RemoteCertRequired = RemoteCertRequired,
+ CheckCertName = CheckCertName,
+ CertValidationDelegate = CertValidationDelegate,
+ CertSelectionDelegate = CertSelectionDelegate,
+ ServerCertSelectionDelegate = ServerCertSelectionDelegate,
+ CipherSuitesPolicy = CipherSuitesPolicy,
+ UserState = UserState,
+ ServerOptionDelegate = ServerOptionDelegate,
+ CertificateChainPolicy = CertificateChainPolicy,
+ AllowTlsResume = AllowTlsResume,
+ AllowRsaPssPadding = AllowRsaPssPadding,
+ AllowRsaPkcs1Padding = AllowRsaPkcs1Padding,
+ ForceSyncPal = ForceSyncPal,
+ };
+ return copy;
+ }
+
+ // Bulk-copy field values from another options bag into this one. Used by
+ // TlsSession.SetContext to inherit a fully-configured server context's
+ // options into an existing session (whose bag was originally created empty
+ // from a deferred TlsContext.Create((SslServerAuthenticationOptions?)null)).
+ // Mirrors the field set copied by Clone(). Session-scoped state (SafeSslHandle,
+ // RemoteCertificateValidator, SocketHandle, ReplayPrefix, PreallocatedSslContext)
+ // is intentionally NOT copied — those belong to the receiving session.
+ internal void CopyFrom(SslAuthenticationOptions other)
+ {
+ AllowRenegotiation = other.AllowRenegotiation;
+ TargetHost = other.TargetHost;
+ ClientCertificates = other.ClientCertificates;
+ ApplicationProtocols = other.ApplicationProtocols;
+ IsServer = other.IsServer;
+ CertificateContext = other.CertificateContext;
+ OwnsCertificateContext = false;
+ EnabledSslProtocols = other.EnabledSslProtocols;
+ CertificateRevocationCheckMode = other.CertificateRevocationCheckMode;
+ EncryptionPolicy = other.EncryptionPolicy;
+ RemoteCertRequired = other.RemoteCertRequired;
+ CheckCertName = other.CheckCertName;
+ CertValidationDelegate = other.CertValidationDelegate;
+ CertSelectionDelegate = other.CertSelectionDelegate;
+ ServerCertSelectionDelegate = other.ServerCertSelectionDelegate;
+ CipherSuitesPolicy = other.CipherSuitesPolicy;
+ UserState = other.UserState;
+ ServerOptionDelegate = other.ServerOptionDelegate;
+ CertificateChainPolicy = other.CertificateChainPolicy;
+ AllowTlsResume = other.AllowTlsResume;
+ AllowRsaPssPadding = other.AllowRsaPssPadding;
+ AllowRsaPkcs1Padding = other.AllowRsaPkcs1Padding;
+ ForceSyncPal = other.ForceSyncPal;
+ }
+
internal bool AllowRenegotiation { get; set; }
internal string TargetHost { get; set; }
internal X509CertificateCollection? ClientCertificates { get; set; }
internal List? ApplicationProtocols { get; set; }
internal bool IsServer { get; set; }
internal bool IsClient => !IsServer;
- internal SslStreamCertificateContext? CertificateContext { get; private set; }
+ internal SslStreamCertificateContext? CertificateContext { get; set; }
// If true, the certificate context was created by the SslStream and
// certificates inside should be disposed when no longer needed.
- internal bool OwnsCertificateContext { get; private set; }
+ internal bool OwnsCertificateContext { get; set; }
internal SslProtocols EnabledSslProtocols { get; set; }
internal X509RevocationMode CertificateRevocationCheckMode { get; set; }
internal EncryptionPolicy EncryptionPolicy { get; set; }
@@ -218,6 +287,9 @@ internal void SetCertificateContextFromCert(X509Certificate2 certificate, bool?
internal bool AllowTlsResume { get; set; }
internal bool AllowRsaPssPadding { get; set; }
internal bool AllowRsaPkcs1Padding { get; set; }
+ // Set by callers (e.g. TlsSession) whose state machine is intrinsically synchronous
+ // and cannot use the async Network Framework PAL path on macOS.
+ internal bool ForceSyncPal { get; set; }
#if TARGET_ANDROID
internal SslStream.JavaProxy? SslStreamProxy { get; set; }
@@ -225,6 +297,25 @@ internal void SetCertificateContextFromCert(X509Certificate2 certificate, bool?
#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
internal SslStream? SslStream { get; set; }
+
+ // Set by SafeSslHandle.Create so OpenSSL's CertVerifyCallback can stash
+ // a CertificateValidationException on the handle when validation fails.
+ // Typed as the base SafeHandle so this file compiles in test projects
+ // that don't include the OpenSSL interop sources.
+ internal System.Runtime.InteropServices.SafeHandle? SafeSslHandle { get; set; }
+
+ // Hook invoked by OpenSSL's CertVerifyCallback to drive remote
+ // certificate validation. Set by SslStream and by standalone TlsSession
+ // so both flows share the same callback plumbing.
+ internal delegate bool VerifyRemoteCertificateCallback(
+ X509Certificate2? certificate,
+ X509Chain? chain,
+ SslCertificateTrust? trust,
+ ref ProtocolToken alertToken,
+ out SslPolicyErrors sslPolicyErrors,
+ out X509ChainStatusFlags chainStatus);
+
+ internal VerifyRemoteCertificateCallback? RemoteCertificateValidator { get; set; }
#endif
public void Dispose()
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslSessionsCache.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslSessionsCache.cs
index d69e86b5501d1f..812df48f2d481f 100644
--- a/src/libraries/System.Net.Security/src/System/Net/Security/SslSessionsCache.cs
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslSessionsCache.cs
@@ -114,15 +114,14 @@ public bool Equals(SslCredKey other)
bool allowRsaPssPadding,
bool allowRsaPkcs1Padding)
{
+ var key = new SslCredKey(thumbPrint, (int)sslProtocols, isServer, encryptionPolicy, sendTrustList, checkRevocation, allowTlsResume, allowRsaPssPadding, allowRsaPkcs1Padding);
+
if (s_cachedCreds.IsEmpty)
{
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(null, $"Not found, Current Cache Count = {s_cachedCreds.Count}");
return null;
}
- var key = new SslCredKey(thumbPrint, (int)sslProtocols, isServer, encryptionPolicy, sendTrustList, checkRevocation, allowTlsResume, allowRsaPssPadding, allowRsaPkcs1Padding);
-
- //SafeCredentialReference? cached;
SafeFreeCredentials? credentials = GetCachedCredential(key);
if (credentials == null || credentials.IsClosed || credentials.IsInvalid || credentials.Expiry < DateTime.UtcNow)
{
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs
index 39cfac184361af..57ca00eb73de76 100644
--- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs
@@ -281,9 +281,28 @@ private async Task ForceAuthenticationAsync(bool receiveFirst, byte[
#if TARGET_APPLE
if (SslStreamPal.ShouldUseAsyncSecurityContext(_sslAuthenticationOptions))
{
- Debug.Assert(_sslAuthenticationOptions.IsClient);
byte[]? dummy = null;
- AcquireClientCredentials(ref dummy, true);
+ if (_sslAuthenticationOptions.IsClient)
+ {
+ AcquireClientCredentials(ref dummy, true);
+ }
+ else if (_sslAuthenticationOptions.ServerCertSelectionDelegate is null &&
+ _sslAuthenticationOptions.CertSelectionDelegate is { } certSelectionDelegate)
+ {
+ // Match legacy SecureTransport PAL: when CertSelectionDelegate is
+ // configured and returns null, surface NotSupportedException
+ // instead of timing out the handshake.
+ var tempCollection = new X509CertificateCollection();
+ if (_sslAuthenticationOptions.CertificateContext?.TargetCertificate is X509Certificate2 ctx)
+ {
+ tempCollection.Add(ctx);
+ }
+ X509Certificate? selected = certSelectionDelegate(this, string.Empty, tempCollection, null, Array.Empty());
+ if (selected is null)
+ {
+ throw new NotSupportedException(SR.net_ssl_io_no_server_cert);
+ }
+ }
Task handshakeTask = SslStreamPal.AsyncHandshakeAsync(ref _securityContext, this, cancellationToken);
await TIOAdapter.WaitAsync(handshakeTask).ConfigureAwait(false);
@@ -300,6 +319,10 @@ private async Task ForceAuthenticationAsync(bool receiveFirst, byte[
CompleteHandshake(_sslAuthenticationOptions);
return;
}
+ else
+ {
+ _sslAuthenticationOptions.ForceSyncPal = true;
+ }
#endif // TARGET_APPLE
if (!receiveFirst)
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.NotUnix.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.NotUnix.cs
new file mode 100644
index 00000000000000..fe532408ebe4e6
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.NotUnix.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace System.Net.Security
+{
+ // Stub partial impls for non-Linux/FreeBSD platforms. Always returns false so
+ // SslStream's existing PAL paths run unchanged.
+ public partial class SslStream
+ {
+#pragma warning disable CA1822 // partial method signature must match the Unix impl which is non-static
+ private partial bool TryNextMessageViaTlsSession(ReadOnlySpan incomingBuffer, out ProtocolToken token, out int consumed)
+ {
+ token = default;
+ consumed = 0;
+ return false;
+ }
+#pragma warning restore CA1822
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs
index da230c89bf986e..ade55b172b737a 100644
--- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs
@@ -787,6 +787,15 @@ static DateTime GetExpiryTimestamp(SslStreamCertificateContext certificateContex
//
internal ProtocolToken NextMessage(ReadOnlySpan incomingBuffer, out int consumed)
{
+ if (TryNextMessageViaTlsSession(incomingBuffer, out ProtocolToken wedged, out consumed))
+ {
+ if (NetEventSource.Log.IsEnabled() && wedged.Failed)
+ {
+ NetEventSource.Error(this, $"Authentication failed. Status: {wedged.Status}, Exception message: {wedged.GetException()!.Message}");
+ }
+ return wedged;
+ }
+
ProtocolToken token = GenerateToken(incomingBuffer, out consumed);
if (NetEventSource.Log.IsEnabled())
{
@@ -799,6 +808,8 @@ internal ProtocolToken NextMessage(ReadOnlySpan incomingBuffer, out int co
return token;
}
+ private partial bool TryNextMessageViaTlsSession(ReadOnlySpan incomingBuffer, out ProtocolToken token, out int consumed);
+
/*++
GenerateToken - Called after each successive state
in the Client - Server handshake. This function
@@ -1161,17 +1172,48 @@ internal bool VerifyRemoteCertificate(
ref ProtocolToken alertToken,
out SslPolicyErrors sslPolicyErrors,
out X509ChainStatusFlags chainStatus)
+ {
+ return VerifyRemoteCertificateCore(
+ this,
+ _sslAuthenticationOptions,
+ _securityContext,
+ ref _remoteCertificate,
+ ref _connectionInfo,
+ certificate,
+ chain,
+ trust,
+ ref alertToken,
+ out sslPolicyErrors,
+ out chainStatus);
+ }
+
+ internal static bool VerifyRemoteCertificateCore(
+ object sender,
+ SslAuthenticationOptions sslAuthenticationOptions,
+#if TARGET_APPLE
+ SafeDeleteContext? securityContext,
+#else
+ SafeDeleteSslContext? securityContext,
+#endif
+ ref X509Certificate2? remoteCertificateSlot,
+ ref SslConnectionInfo connectionInfo,
+ X509Certificate2? certificate,
+ X509Chain? chain,
+ SslCertificateTrust? trust,
+ ref ProtocolToken alertToken,
+ out SslPolicyErrors sslPolicyErrors,
+ out X509ChainStatusFlags chainStatus)
{
sslPolicyErrors = SslPolicyErrors.None;
chainStatus = X509ChainStatusFlags.NoError;
bool success = false;
- RemoteCertificateValidationCallback? remoteCertValidationCallback = _sslAuthenticationOptions.CertValidationDelegate;
+ RemoteCertificateValidationCallback? remoteCertValidationCallback = sslAuthenticationOptions.CertValidationDelegate;
- if (_remoteCertificate != null &&
+ if (remoteCertificateSlot != null &&
certificate != null &&
- certificate.RawDataMemory.Span.SequenceEqual(_remoteCertificate.RawDataMemory.Span))
+ certificate.RawDataMemory.Span.SequenceEqual(remoteCertificateSlot.RawDataMemory.Span))
{
// This is renegotiation or TLS 1.3 post-handshake auth and the (remote) certificate did not change.
// Revalidating the same certificate MAY fail for a couple of reasons (expiration, revocation,
@@ -1181,13 +1223,13 @@ internal bool VerifyRemoteCertificate(
return true;
}
- // don't assign to _remoteCertificate yet, this prevents weird exceptions if SslStream is disposed in parallel with X509Chain building
+ // don't assign to remoteCertificateSlot yet, this prevents weird exceptions if SslStream is disposed in parallel with X509Chain building
if (certificate == null)
{
- if (NetEventSource.Log.IsEnabled() && RemoteCertRequired)
+ if (NetEventSource.Log.IsEnabled() && sslAuthenticationOptions.RemoteCertRequired)
{
- NetEventSource.Error(this, $"Remote certificate required, but no remote certificate received");
+ NetEventSource.Error(sender, $"Remote certificate required, but no remote certificate received");
}
sslPolicyErrors |= SslPolicyErrors.RemoteCertificateNotAvailable;
}
@@ -1195,16 +1237,16 @@ internal bool VerifyRemoteCertificate(
{
chain ??= new X509Chain();
- if (_sslAuthenticationOptions.CertificateChainPolicy != null)
+ if (sslAuthenticationOptions.CertificateChainPolicy != null)
{
- chain.ChainPolicy = _sslAuthenticationOptions.CertificateChainPolicy;
+ chain.ChainPolicy = sslAuthenticationOptions.CertificateChainPolicy;
}
else
{
- chain.ChainPolicy.RevocationMode = _sslAuthenticationOptions.CertificateRevocationCheckMode;
+ chain.ChainPolicy.RevocationMode = sslAuthenticationOptions.CertificateRevocationCheckMode;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;
- if (_sslAuthenticationOptions.IsServer && !LocalAppContextSwitches.EnableServerAiaDownloads)
+ if (sslAuthenticationOptions.IsServer && !LocalAppContextSwitches.EnableServerAiaDownloads)
{
chain.ChainPolicy.DisableCertificateDownloads = true;
}
@@ -1227,36 +1269,36 @@ internal bool VerifyRemoteCertificate(
if (chain.ChainPolicy.ApplicationPolicy.Count == 0)
{
// Authenticate the remote party: (e.g. when operating in server mode, authenticate the client).
- chain.ChainPolicy.ApplicationPolicy.Add(_sslAuthenticationOptions.IsServer ? s_clientAuthOid : s_serverAuthOid);
+ chain.ChainPolicy.ApplicationPolicy.Add(sslAuthenticationOptions.IsServer ? s_clientAuthOid : s_serverAuthOid);
}
sslPolicyErrors |= CertificateValidationPal.VerifyCertificateProperties(
- _securityContext!,
+ securityContext!,
chain,
certificate,
- _sslAuthenticationOptions.CheckCertName,
- _sslAuthenticationOptions.IsServer,
- TargetHostNameHelper.NormalizeHostName(_sslAuthenticationOptions.TargetHost));
+ sslAuthenticationOptions.CheckCertName,
+ sslAuthenticationOptions.IsServer,
+ TargetHostNameHelper.NormalizeHostName(sslAuthenticationOptions.TargetHost));
}
- _remoteCertificate = certificate;
+ remoteCertificateSlot = certificate;
if (remoteCertValidationCallback != null)
{
// Ensure connection info is populated before calling the user callback,
// which may access properties like SslProtocol or CipherAlgorithm.
// During inline cert validation the handshake hasn't completed yet, so
- // _connectionInfo may not have been set by ProcessHandshakeSuccess.
- if (_connectionInfo.Protocol == 0 && _securityContext is not null)
+ // connectionInfo may not have been set by ProcessHandshakeSuccess.
+ if (connectionInfo.Protocol == 0 && securityContext is not null)
{
- SslStreamPal.QueryContextConnectionInfo(_securityContext, ref _connectionInfo);
+ SslStreamPal.QueryContextConnectionInfo(securityContext, ref connectionInfo);
}
- success = remoteCertValidationCallback(this, certificate, chain, sslPolicyErrors);
+ success = remoteCertValidationCallback(sender, certificate, chain, sslPolicyErrors);
}
else
{
- if (!RemoteCertRequired)
+ if (!sslAuthenticationOptions.RemoteCertRequired)
{
sslPolicyErrors &= ~SslPolicyErrors.RemoteCertificateNotAvailable;
}
@@ -1266,16 +1308,16 @@ internal bool VerifyRemoteCertificate(
if (NetEventSource.Log.IsEnabled())
{
- LogCertificateValidation(remoteCertValidationCallback, sslPolicyErrors, success, chain);
- NetEventSource.Info(this, $"Cert validation, remote cert = {_remoteCertificate}");
+ LogCertificateValidation(sender, remoteCertValidationCallback, sslPolicyErrors, success, chain);
+ NetEventSource.Info(sender, $"Cert validation, remote cert = {remoteCertificateSlot}");
}
if (!success)
{
#pragma warning disable CS0162 // unreachable code detected (compile time const)
- if (SslStreamPal.CanGenerateCustomAlertsForContext(_securityContext) && !SslStreamPal.CertValidationInCallback)
+ if (SslStreamPal.CanGenerateCustomAlertsForContext(securityContext) && !SslStreamPal.CertValidationInCallback && sender is SslStream sslStream)
{
- CreateFatalHandshakeAlertToken(sslPolicyErrors, chain!, ref alertToken);
+ sslStream.CreateFatalHandshakeAlertToken(sslPolicyErrors, chain!, ref alertToken);
}
#pragma warning restore CS0162 // unreachable code detected (compile time const)
@@ -1416,22 +1458,22 @@ internal static TlsAlertMessage GetAlertMessageFromChain(X509Chain chain)
return TlsAlertMessage.BadCertificate;
}
- private void LogCertificateValidation(RemoteCertificateValidationCallback? remoteCertValidationCallback, SslPolicyErrors sslPolicyErrors, bool success, X509Chain? chain)
+ private static void LogCertificateValidation(object sender, RemoteCertificateValidationCallback? remoteCertValidationCallback, SslPolicyErrors sslPolicyErrors, bool success, X509Chain? chain)
{
if (!NetEventSource.Log.IsEnabled())
return;
if (sslPolicyErrors != SslPolicyErrors.None)
{
- NetEventSource.Log.RemoteCertificateError(this, SR.net_log_remote_cert_has_errors);
+ NetEventSource.Log.RemoteCertificateError(sender, SR.net_log_remote_cert_has_errors);
if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNotAvailable) != 0)
{
- NetEventSource.Log.RemoteCertificateError(this, SR.net_log_remote_cert_not_available);
+ NetEventSource.Log.RemoteCertificateError(sender, SR.net_log_remote_cert_not_available);
}
if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
{
- NetEventSource.Log.RemoteCertificateError(this, SR.net_log_remote_cert_name_mismatch);
+ NetEventSource.Log.RemoteCertificateError(sender, SR.net_log_remote_cert_name_mismatch);
}
if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateChainErrors) != 0)
@@ -1442,7 +1484,7 @@ private void LogCertificateValidation(RemoteCertificateValidationCallback? remot
{
chainStatusString += "\t" + chainStatus.StatusInformation;
}
- NetEventSource.Log.RemoteCertificateError(this, chainStatusString);
+ NetEventSource.Log.RemoteCertificateError(sender, chainStatusString);
}
}
@@ -1450,18 +1492,18 @@ private void LogCertificateValidation(RemoteCertificateValidationCallback? remot
{
if (remoteCertValidationCallback != null)
{
- NetEventSource.Log.RemoteCertDeclaredValid(this);
+ NetEventSource.Log.RemoteCertDeclaredValid(sender);
}
else
{
- NetEventSource.Log.RemoteCertHasNoErrors(this);
+ NetEventSource.Log.RemoteCertHasNoErrors(sender);
}
}
else
{
if (remoteCertValidationCallback != null)
{
- NetEventSource.Log.RemoteCertUserDeclaredInvalid(this);
+ NetEventSource.Log.RemoteCertUserDeclaredInvalid(sender);
}
}
}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.TlsSessionWedge.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.TlsSessionWedge.cs
new file mode 100644
index 00000000000000..4c0dd35ee8ed07
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.TlsSessionWedge.cs
@@ -0,0 +1,154 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Security.Cryptography.X509Certificates;
+
+namespace System.Net.Security
+{
+ // Routes the SslStream handshake hot-path through TlsSession. The PAL calls
+ // underneath are unchanged; this is a wedge that proves TlsSession is
+ // expressive enough to host SslStream's TLS engine. Compiled on Linux,
+ // FreeBSD, and Windows.
+ //
+ // SslStream's _securityContext / _credentialsHandle fields are mirrored from
+ // the TlsSession after each step so that the rest of SslStream (cert
+ // validation, channel binding, ProcessHandshakeSuccess, renegotiation,
+ // dispose) continues to work against the same SafeHandles.
+ public partial class SslStream
+ {
+ private TlsBufferSession? _tlsSession;
+
+ private void EnsureTlsSession()
+ {
+ if (_tlsSession is null)
+ {
+ Debug.Assert(_sslAuthenticationOptions != null);
+ TlsContext ctx = TlsContext.WrapShared(_sslAuthenticationOptions);
+ _tlsSession = new TlsBufferSession();
+ _tlsSession.SetContext(ctx);
+
+ // SslStream owns post-handshake certificate validation (see
+ // SslStream.IO.cs ProcessHandshakeSuccess). Tell TlsSession not to run
+ // its own callback so the user delegate sees the SslStream as sender
+ // and isn't invoked twice.
+ _tlsSession.SuppressInternalCertificateValidation = true;
+ }
+ }
+
+ private partial bool TryNextMessageViaTlsSession(ReadOnlySpan incomingBuffer, out ProtocolToken token, out int consumed)
+ {
+ EnsureTlsSession();
+
+ // The legacy GenerateToken acquires credentials before the first PAL call.
+ // On Unix AcquireCredentialsHandle is a no-op (returns null), but
+ // AcquireServerCredentials has a side effect we must preserve: it resolves
+ // the cert via ServerCertSelectionDelegate / CertSelectionDelegate /
+ // CertificateContext and assigns _sslAuthenticationOptions.CertificateContext,
+ // which the OpenSSL handshake asserts on. AcquireClientCredentials similarly
+ // bootstraps the client cert context. Run them once per handshake before the
+ // first PAL call.
+ bool refreshCredentialNeeded = _securityContext is null;
+ bool cachedCreds = false;
+ bool sendTrustList = false;
+ byte[]? thumbPrint = null;
+ try
+ {
+ if (refreshCredentialNeeded)
+ {
+ if (_sslAuthenticationOptions!.IsServer)
+ {
+ sendTrustList = _sslAuthenticationOptions.CertificateContext?.Trust?._sendTrustInHandshake ?? false;
+ cachedCreds = AcquireServerCredentials(ref thumbPrint);
+ }
+ else
+ {
+ cachedCreds = AcquireClientCredentials(ref thumbPrint);
+ }
+
+ // SChannel-style PALs populate SslStream._credentialsHandle from
+ // SslSessionsCache before the first ASC/ISC. Seed TlsSession with it
+ // so its ref parameter starts from the cached handle rather than null.
+ _tlsSession!.CredentialsHandle = _credentialsHandle;
+ }
+
+ token = _tlsSession!.HandshakeStepForSslStream(incomingBuffer, out consumed);
+
+ // SChannel server-side ALPN: when the first ASC call returns
+ // HandshakeStarted, the wire bytes were consumed but ASC stopped so we
+ // can run SelectApplicationProtocol with the parsed ClientHello before
+ // generating the ServerHello. Re-enter with no new input afterwards.
+ if (token.Status.ErrorCode == SecurityStatusPalErrorCode.HandshakeStarted)
+ {
+ token.Status = SslStreamPal.SelectApplicationProtocol(
+ _tlsSession.CredentialsHandle!,
+ _tlsSession.SecurityContext!,
+ _sslAuthenticationOptions!,
+ _lastFrame.RawApplicationProtocols);
+
+ if (token.Status.ErrorCode == SecurityStatusPalErrorCode.OK)
+ {
+ token = _tlsSession.HandshakeStepForSslStream(ReadOnlySpan.Empty, out _);
+ }
+ }
+
+ // OpenSSL surfaces CredentialsNeeded when the local cert callback returned
+ // null on the first call. SChannel surfaces it on a later ISC step after
+ // the server's CertificateRequest is parsed. Re-run client cert selection
+ // with newCredentialsRequested=true (mirrors legacy GenerateToken), then
+ // drive the handshake again with no new input. Set refreshCredentialNeeded
+ // so the finally-block caches the new cert-bound credential.
+ if (token.Status.ErrorCode == SecurityStatusPalErrorCode.CredentialsNeeded)
+ {
+ if (NetEventSource.Log.IsEnabled())
+ {
+ NetEventSource.Info(this, "TlsSession reported 'CredentialsNeeded'; reselecting client credentials.");
+ }
+
+ refreshCredentialNeeded = true;
+ cachedCreds = AcquireClientCredentials(ref thumbPrint, newCredentialsRequested: true);
+ _tlsSession.CredentialsHandle = _credentialsHandle;
+
+ token = _tlsSession.HandshakeStepForSslStream(ReadOnlySpan.Empty, out _);
+ }
+
+ // Mirror handles so legacy SslStream paths (cert validation, channel binding,
+ // ProcessHandshakeSuccess, renegotiation, Dispose) keep working unchanged.
+ _securityContext = _tlsSession.SecurityContext;
+ _credentialsHandle = _tlsSession.CredentialsHandle;
+ }
+ finally
+ {
+ if (refreshCredentialNeeded)
+ {
+ // Mirror legacy GenerateToken bookkeeping: the PAL has bumped the cred
+ // refcount, so drop our reference. Then publish a fresh entry to
+ // SslSessionsCache so subsequent connections to the same host can
+ // resume the TLS session (Windows SChannel session ticket lives on
+ // the cred handle).
+ _credentialsHandle?.Dispose();
+
+ bool wouldCache = !cachedCreds && _securityContext is not null && !_securityContext.IsInvalid &&
+ _credentialsHandle is not null && !_credentialsHandle.IsInvalid;
+
+ if (wouldCache)
+ {
+ SslSessionsCache.CacheCredential(
+ _credentialsHandle!,
+ thumbPrint,
+ _sslAuthenticationOptions!.EnabledSslProtocols,
+ _sslAuthenticationOptions.IsServer,
+ _sslAuthenticationOptions.EncryptionPolicy,
+ _sslAuthenticationOptions.CertificateRevocationCheckMode != X509RevocationMode.NoCheck,
+ _sslAuthenticationOptions.AllowTlsResume,
+ sendTrustList,
+ _sslAuthenticationOptions.AllowRsaPssPadding,
+ _sslAuthenticationOptions.AllowRsaPkcs1Padding);
+ }
+ }
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.cs
index 41019a73cd6103..db932eba59193a 100644
--- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.cs
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.cs
@@ -222,6 +222,7 @@ public SslStream(Stream innerStream, bool leaveInnerStreamOpen, RemoteCertificat
#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
_sslAuthenticationOptions.SslStream = this;
+ _sslAuthenticationOptions.RemoteCertificateValidator = VerifyRemoteCertificate;
#endif
if (NetEventSource.Log.IsEnabled()) NetEventSource.Log.SslStreamCtor(this, innerStream);
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs
index 9386e8dcc10c68..a55ade929bb79f 100644
--- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs
@@ -104,7 +104,7 @@ public static SecurityStatusPal SelectApplicationProtocol(
#pragma warning disable IDE0060
public static ProtocolToken AcceptSecurityContext(
- ref SafeFreeCredentials credential,
+ ref SafeFreeCredentials? credential,
ref SafeDeleteContext? context,
ReadOnlySpan inputBuffer,
out int consumed,
@@ -114,7 +114,7 @@ public static ProtocolToken AcceptSecurityContext(
}
public static ProtocolToken InitializeSecurityContext(
- ref SafeFreeCredentials credential,
+ ref SafeFreeCredentials? credential,
ref SafeDeleteContext? context,
string? _ /*targetName*/,
ReadOnlySpan inputBuffer,
@@ -371,6 +371,14 @@ private static ProtocolToken HandshakeInternal(
context = new SafeDeleteSslContext(sslAuthenticationOptions);
}
+ if (context is SafeDeleteNwContext)
+ {
+ // Network Framework drives the handshake and shutdown internally;
+ // there is no synchronous token to emit here.
+ token.Status = new SecurityStatusPal(SecurityStatusPalErrorCode.OK);
+ return token;
+ }
+
Debug.Assert(context is SafeDeleteSslContext, "SafeDeleteSslContext expected");
SafeDeleteSslContext sslContext = (SafeDeleteSslContext)context;
@@ -486,9 +494,19 @@ internal static bool ShouldUseAsyncSecurityContext(SslAuthenticationOptions sslA
private static bool ShouldUseNetworkFramework(
SslAuthenticationOptions sslAuthenticationOptions)
{
+ // Transparently fall back to legacy SecureTransport for any configuration
+ // Network Framework cannot satisfy, instead of throwing PlatformNotSupportedException.
+#pragma warning disable SYSLIB0040 // NoEncryption and AllowNoEncryption are obsolete
+ bool encryptionPolicyOk =
+ sslAuthenticationOptions.EncryptionPolicy == EncryptionPolicy.RequireEncryption ||
+ sslAuthenticationOptions.EncryptionPolicy == EncryptionPolicy.AllowNoEncryption;
+#pragma warning restore SYSLIB0040
+
return
- sslAuthenticationOptions.IsClient &&
SafeDeleteNwContext.IsNetworkFrameworkAvailable &&
+ !sslAuthenticationOptions.ForceSyncPal &&
+ encryptionPolicyOk &&
+ (sslAuthenticationOptions.IsClient || sslAuthenticationOptions.CertificateContext != null) &&
(sslAuthenticationOptions.EnabledSslProtocols == SslProtocols.None ||
sslAuthenticationOptions.EnabledSslProtocols == SslProtocols.Tls13 ||
(sslAuthenticationOptions.EnabledSslProtocols == (SslProtocols.Tls12 | SslProtocols.Tls13)));
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsBufferSession.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsBufferSession.cs
new file mode 100644
index 00000000000000..794d80b28d2eb1
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsBufferSession.cs
@@ -0,0 +1,56 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace System.Net.Security
+{
+ ///
+ /// Non-blocking TLS state machine driven by caller-supplied byte spans.
+ /// The session performs no I/O; the caller feeds ciphertext in and drains
+ /// ciphertext out via the buffered , ,
+ /// , , and
+ /// methods.
+ ///
+ ///
+ /// A newly-constructed instance has no . Call
+ /// with a client or server context before
+ /// invoking any operation. Server-side deferred flow (SNI-driven context
+ /// selection) is supported by passing an empty
+ /// to TlsContext.CreateServer;
+ /// the first Handshake call then suspends on
+ /// so the caller can supply the
+ /// resolved per-tenant context via .
+ ///
+ [Experimental(Experimentals.LowLevelTlsDiagId, UrlFormat = Experimentals.SharedUrlFormat)]
+ public sealed class TlsBufferSession : TlsSession
+ {
+ public TlsBufferSession()
+ {
+ }
+
+ /// Drives the TLS handshake forward using caller-supplied ciphertext.
+ public TlsOperationStatus Handshake(ReadOnlySpan source, Span destination, out int bytesConsumed, out int bytesWritten)
+ => HandshakeBufferedCore(source, destination, out bytesConsumed, out bytesWritten);
+
+ /// Encrypts application-plaintext into ciphertext records.
+ public TlsOperationStatus Write(ReadOnlySpan source, Span destination, out int bytesConsumed, out int bytesWritten)
+ => WriteBufferedCore(source, destination, out bytesConsumed, out bytesWritten);
+
+ /// Decrypts ciphertext records into application-plaintext.
+ public TlsOperationStatus Read(ReadOnlySpan source, Span destination, out int bytesConsumed, out int bytesWritten)
+ => ReadBufferedCore(source, destination, out bytesConsumed, out bytesWritten);
+
+ /// Initiates a TLS close_notify alert; writes the alert record into .
+ public TlsOperationStatus Shutdown(Span ciphertext, out int bytesWritten)
+ => ShutdownBufferedCore(ciphertext, out bytesWritten);
+
+ /// Drains any staged pending output (handshake fragments, alerts, encrypted records) into .
+ public TlsOperationStatus DrainPendingOutput(Span ciphertext, out int bytesWritten)
+ => DrainPendingOutputCore(ciphertext, out bytesWritten);
+
+ /// Server-side only. Sends a CertificateRequest to the peer as part of TLS 1.3 post-handshake authentication.
+ public TlsOperationStatus RequestClientCertificate(Span ciphertext, out int bytesWritten)
+ => RequestClientCertificateBufferedCore(ciphertext, out bytesWritten);
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.OpenSsl.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.OpenSsl.cs
new file mode 100644
index 00000000000000..f73bcfd84f597d
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.OpenSsl.cs
@@ -0,0 +1,73 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Win32.SafeHandles;
+
+namespace System.Net.Security
+{
+ public sealed partial class TlsContext
+ {
+ // Long-lived OpenSSL SSL_CTX shared by every TlsSession created from this
+ // TlsContext. Allocated lazily on the first session's handshake (via
+ // AttachSharedNativeContext) so we can defer until cipher/protocol/cert
+ // settings on the options bag are finalized. Disposed with the TlsContext.
+ //
+ // Cert/key/ALPN/SNI that are intrinsically per-session continue to live on
+ // the SSL* handle (SafeSslHandle); the SSL_CTX carries only the bits that
+ // are stable across every session: protocol mask, cipher list, encryption
+ // policy, the cert-verify callback wiring, and the session-resume cache.
+ private SafeSslContextHandle? _sslContext;
+ private readonly object _sslContextLock = new object();
+
+ partial void AttachSharedNativeContext(SslAuthenticationOptions sessionOptions)
+ {
+ // Wedge mode reuses SslStream's existing per-handshake SSL_CTX caching path;
+ // don't allocate a TlsContext-owned SSL_CTX that would conflict with it.
+ if (_isWedge)
+ {
+ return;
+ }
+
+ // AllocateSslContext attaches the server cert directly to SSL_CTX. When the
+ // server cert isn't known up front (ServerCertificateSelectionCallback or
+ // deferred SetContext), each session resolves its own cert after the
+ // ClientHello, so a shared SSL_CTX would carry no cert. Fall back to the
+ // per-session allocation path in AllocateSslHandle.
+ if (_options.IsServer && _options.CertificateContext is null)
+ {
+ return;
+ }
+
+ SafeSslContextHandle? ctx = _sslContext;
+ if (ctx is null)
+ {
+ lock (_sslContextLock)
+ {
+ ctx = _sslContext;
+ if (ctx is null)
+ {
+ // allowCached:false bypasses the global SslContextCacheKey lookup;
+ // the handle returned is exclusively owned by this TlsContext.
+ // enableResume honors the AllowTlsResume option on the bag so
+ // server-side session resume works against this owned SSL_CTX.
+ bool enableResume = sessionOptions.AllowTlsResume
+ && !LocalAppContextSwitches.DisableTlsResume
+ && sessionOptions.EncryptionPolicy == EncryptionPolicy.RequireEncryption
+ && sessionOptions.CipherSuitesPolicy == null;
+ ctx = Interop.OpenSsl.GetOrCreateSslContextHandle(sessionOptions, allowCached: false, enableResume: enableResume);
+ _sslContext = ctx;
+ }
+ }
+ }
+
+ sessionOptions.PreallocatedSslContext = ctx;
+ }
+
+ partial void DisposeNativeContext()
+ {
+ SafeSslContextHandle? ctx = _sslContext;
+ _sslContext = null;
+ ctx?.Dispose();
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs
new file mode 100644
index 00000000000000..428f4c40c574c2
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs
@@ -0,0 +1,159 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+namespace System.Net.Security
+{
+ ///
+ /// Long-lived TLS configuration. Wraps an
+ /// constructed from either or
+ /// . Role (client vs. server) is
+ /// determined by which factory is used.
+ ///
+ ///
+ /// Holds the resolved options bag. Multi-connection sharing / session
+ /// cache reuse is not yet wired through; each
+ /// gets its own native context allocated lazily on the first handshake call.
+ ///
+ [Experimental(Experimentals.LowLevelTlsDiagId, UrlFormat = Experimentals.SharedUrlFormat)]
+ public sealed partial class TlsContext : IDisposable
+ {
+ private readonly SslAuthenticationOptions _options;
+ // True when this context wraps an SslStream-owned options bag (wedge mode):
+ // sessions share the same bag by reference and TlsContext does not dispose it,
+ // does not allocate its own native context, and defers cred-handle lifetime to
+ // the wrapper. False for standalone contexts created via Create(...).
+ private readonly bool _isWedge;
+ private readonly bool _templateHasServerOptions;
+
+ // SChannel credentials handle (an SSPI CredHandle from AcquireCredentialsHandle).
+ // Owned by TlsContext so it can be shared across multiple TlsSession instances.
+ // In wedge mode (WrapShared) SslStream owns the lifetime and we skip disposing
+ // here to avoid double-free. Stays null on Unix — the OpenSSL SSL_CTX equivalent
+ // lives in TlsContext.OpenSsl.cs.
+ internal SafeFreeCredentials? CredentialsHandle;
+
+ private TlsContext(SslAuthenticationOptions options, bool isWedge, bool templateHasServerOptions)
+ {
+ _options = options;
+ _isWedge = isWedge;
+ _templateHasServerOptions = templateHasServerOptions;
+ }
+
+ internal SslAuthenticationOptions Options => _options;
+
+ // Internal accessor for the obsolete EncryptionPolicy carried in options. Not exposed
+ // publicly: a brand-new type should not re-publish a SYSLIB0040-obsolete concept. The
+ // setting is honored at handshake time via the options bag; internal consumers that
+ // need to introspect it (e.g. SslStream when re-platformed on TlsSession) read it here.
+ internal EncryptionPolicy EncryptionPolicy => _options.EncryptionPolicy;
+
+ // True when sessions should reuse the context's options bag directly (wedge mode).
+ // False when each session must take a private clone before mutating any field.
+ internal bool ShareOptions => _isWedge;
+
+ // True if the template was constructed with non-null server options. Sessions seed
+ // their own per-session HasServerOptions from this and flip it in SetContext.
+ internal bool TemplateHasServerOptions => _templateHasServerOptions;
+
+ // Returns a per-session options bag. For normal contexts each call returns a fresh
+ // clone of the template so session-scoped mutations (TargetHost, SafeSslHandle,
+ // resolved server cert, ...) don't leak between sessions. In wedge mode the bag is
+ // owned by SslStream and we hand it out by reference. On platforms that own a
+ // long-lived native context (e.g. OpenSSL SSL_CTX), the platform partial stamps it
+ // onto the returned bag so the PAL can reuse it across sessions.
+ internal SslAuthenticationOptions CreateSessionOptions()
+ {
+ SslAuthenticationOptions sessionOptions = _isWedge ? _options : _options.Clone();
+ sessionOptions.ForceSyncPal = true;
+ AttachSharedNativeContext(sessionOptions);
+ return sessionOptions;
+ }
+
+ // Platform hook: lets the OpenSSL partial attach the TlsContext-owned SSL_CTX to
+ // the per-session options bag. No-op on Windows (which uses CredentialsHandle) and
+ // on macOS/iOS/Android (no reusable native context to share).
+ partial void AttachSharedNativeContext(SslAuthenticationOptions sessionOptions);
+
+ // Platform hook: lets the OpenSSL partial dispose the owned SSL_CTX. No-op elsewhere.
+ partial void DisposeNativeContext();
+
+ internal bool IsServer => _options.IsServer;
+
+ ///
+ /// Creates a server-side TLS context.
+ ///
+ ///
+ /// The server authentication options. May be a default-constructed instance
+ /// (no server certificate, no )
+ /// to defer server configuration: the first
+ /// call on a session built from that context returns
+ /// with
+ /// populated; the caller must then
+ /// invoke before continuing the
+ /// handshake. Useful for SNI-based options selection that involves I/O.
+ ///
+ public static TlsContext CreateServer(SslServerAuthenticationOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ SslAuthenticationOptions bag = new SslAuthenticationOptions();
+ bool hasServerCredentials =
+ options.ServerCertificate != null ||
+ options.ServerCertificateContext != null ||
+ options.ServerCertificateSelectionCallback != null;
+
+ if (!hasServerCredentials)
+ {
+ // Deferred: caller will resolve the credential from SNI and hand back
+ // a completed TlsContext via TlsSession.SetContext.
+ bag.IsServer = true;
+ return new TlsContext(bag, isWedge: false, templateHasServerOptions: false);
+ }
+
+ bag.UpdateOptions(options);
+ return new TlsContext(bag, isWedge: false, templateHasServerOptions: true);
+ }
+
+ ///
+ /// Creates a client-side TLS context.
+ ///
+ ///
+ /// Peer certificate validation always runs outside the TLS state machine: after the
+ /// handshake reaches the point at which the peer cert is available,
+ /// returns and the caller
+ /// must record a result via
+ /// or . Any
+ /// set on
+ /// is invoked only by .
+ ///
+ public static TlsContext CreateClient(SslClientAuthenticationOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ SslAuthenticationOptions bag = new SslAuthenticationOptions();
+ bag.UpdateOptions(options);
+ return new TlsContext(bag, isWedge: false, templateHasServerOptions: false);
+ }
+
+ // Used by SslStream's TlsSession wedge: share the existing options bag so
+ // SNI / client-cert selection results made by SslStream are visible to the
+ // TlsSession-driven PAL calls, and to avoid double Dispose on the bag.
+ internal static TlsContext WrapShared(SslAuthenticationOptions sharedOptions)
+ {
+ Debug.Assert(sharedOptions != null);
+ return new TlsContext(sharedOptions, isWedge: true, templateHasServerOptions: sharedOptions.IsServer);
+ }
+
+ public void Dispose()
+ {
+ if (!_isWedge)
+ {
+ CredentialsHandle?.Dispose();
+ CredentialsHandle = null;
+ DisposeNativeContext();
+ _options.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs
new file mode 100644
index 00000000000000..85193457cc316a
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs
@@ -0,0 +1,66 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace System.Net.Security
+{
+ ///
+ /// Outcome of a non-blocking TLS operation on .
+ /// Provider-opaque; the same values apply across OpenSSL, Schannel, and the
+ /// managed implementation.
+ ///
+ [Experimental(Experimentals.LowLevelTlsDiagId, UrlFormat = Experimentals.SharedUrlFormat)]
+ public enum TlsOperationStatus
+ {
+ /// The call made forward progress.
+ Complete = 0,
+
+ ///
+ /// The destination buffer was too small for the pending output. Call the
+ /// operation again with a larger buffer, or drain via
+ /// .
+ ///
+ DestinationTooSmall = 1,
+
+ ///
+ /// The session needs more ciphertext from the peer to make progress.
+ ///
+ NeedMoreData = 2,
+
+ ///
+ /// The transport is gone or close_notify was received. Dispose the session.
+ ///
+ Closed = 3,
+
+ ///
+ /// The peer requested a client certificate. The caller supplies one (or
+ /// to decline) via
+ /// and re-enters the handshake.
+ ///
+ CertificateRequested = 4,
+
+ ///
+ /// The peer presented a certificate and the TLS state machine has paused so the
+ /// caller can validate it. Retrieve the peer certificate via
+ /// (and any peer-sent intermediates
+ /// via ), perform validation - including
+ /// any I/O such as AIA fetch or CRL/OCSP lookup - on any thread, and then record
+ /// the result with
+ /// .
+ /// Callers that don't need custom validation logic can invoke
+ /// for the equivalent of
+ /// 's default chain build plus user callback.
+ ///
+ NeedsCertificateValidation = 5,
+
+ ///
+ /// Server-side only. The peer's ClientHello has been received but the session
+ /// has no resolved yet (either none was assigned or the
+ /// assigned context is a bootstrap without a server certificate). Inspect
+ /// , supply the resolved context via
+ /// , and continue the handshake.
+ ///
+ NeedsTlsContext = 6,
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.OpenSsl.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.OpenSsl.cs
new file mode 100644
index 00000000000000..4feb3cd910b808
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.OpenSsl.cs
@@ -0,0 +1,309 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO;
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+using System.Security.Authentication;
+using Microsoft.Win32.SafeHandles;
+
+namespace System.Net.Security
+{
+ public abstract partial class TlsSession
+ {
+ // When true, socket-bound I/O delegates ciphertext directly to OpenSSL via
+ // SSL_set_fd / SSL_do_handshake / SSL_read / SSL_write, bypassing the
+ // managed ProcessHandshake/Encrypt/Decrypt loop and its scratch buffers.
+ private bool _useFdMode;
+
+ // Socket that will be bound to OpenSSL once server options are resolved.
+ // Non-null only in the deferred-server socket-bound flow: the session
+ // returns nativeBindingEnabled=false so the managed pre-fetch loop can
+ // parse the ClientHello and surface NeedsServerOptions. OnServerContextSet
+ // then activates fd-mode with the peeked bytes replayed via the socket BIO.
+ private SafeSocketHandle? _pendingFdSocket;
+
+ // Socket-replay BIO populated by TryPeekClientHello via BioReadTlsFrame. Holds
+ // the ClientHello record buffered off the fd; the same BIO becomes the SSL's
+ // read BIO once OnServerContextSet transfers ownership to _options.PreallocatedReadBio.
+ // Freed by OnDispose if the session is disposed before that handoff runs.
+ private SafeBioHandle? _peekBio;
+
+ // Bind the socket directly to the SSL object so OpenSSL drives ciphertext
+ // I/O itself. AllocateSslHandle inspects options.SocketHandle and skips
+ // the ManagedSpanBio installation when set. With fd-mode active, no
+ // managed Socket wrapper is needed - OpenSSL calls recv/send on the fd.
+ //
+ // Server sessions created without options up front (SNI-driven callback)
+ // cannot go fd-mode immediately: SSL_set_fd would let OpenSSL consume the
+ // ClientHello before managed code sees SNI. Defer binding until
+ // OnServerContextSet runs; until then the session uses the managed loop.
+ partial void EnableNativeSocketBinding(SafeSocketHandle socket, ref bool nativeBindingEnabled)
+ {
+ // Defer binding to fd-mode whenever we need to see the ClientHello managed-side
+ // first: either because options aren't set yet (SNI-driven callback flow) or
+ // because ClientHello capture is on (the default). Callers can disable capture
+ // via the System.Net.Security.CaptureClientHello AppContext switch to skip the
+ // peek and take the SSL_set_fd fast path when options are already supplied.
+ if (_context!.IsServer && (!_hasServerOptions || LocalAppContextSwitches.CaptureClientHello))
+ {
+ _pendingFdSocket = socket;
+ nativeBindingEnabled = false;
+ return;
+ }
+
+ _options.SocketHandle = socket;
+ _useFdMode = true;
+ nativeBindingEnabled = true;
+ }
+
+ // Activated when the caller supplies server options in response to
+ // NeedsServerOptions. In the deferred socket-bound flow, hand the peeked
+ // ClientHello bytes to a socket-replay BIO so OpenSSL sees them, then
+ // switch subsequent Handshake/Read/Write calls onto the fd-mode fast path.
+ partial void OnServerContextSet()
+ {
+ if (_pendingFdSocket is null)
+ {
+ return;
+ }
+
+ if (_peekBio is not null)
+ {
+ // Native-peek path (TryPeekClientHello ran): hand the pre-populated
+ // BIO to the options bag; SafeSslHandle.Create adopts it as the read
+ // BIO. No managed byte[] copy, no ReplayPrefix. We keep _peekBio
+ // referenced after transfer so GetClientHelloBytes can span the
+ // retained peek buffer via BioGetPrefix; the SafeBioHandle stays
+ // valid (SSL* is the real owner, our reference DangerousReleases
+ // the parent on session Dispose()).
+ _options.PreallocatedReadBio = _peekBio;
+ }
+ else if (_socketInUsed > 0)
+ {
+ // Legacy managed pre-fetch path: still exercised e.g. by a caller
+ // driving ProcessHandshake directly rather than Handshake(). Copy the
+ // peeked bytes so SafeSslHandle.Create's BioNewSocketReplay-with-prefix
+ // branch can seed the replay BIO.
+ byte[] prefix = new byte[_socketInUsed];
+ Buffer.BlockCopy(_socketInBuf!, 0, prefix, 0, _socketInUsed);
+ _options.ReplayPrefix = prefix;
+ }
+
+ _options.SocketHandle = _pendingFdSocket;
+ _pendingFdSocket = null;
+
+ // Return the managed pre-fetch buffer if we ever rented one.
+ if (_socketInBuf is not null)
+ {
+ System.Buffers.ArrayPool.Shared.Return(_socketInBuf);
+ _socketInBuf = null;
+ _socketInUsed = 0;
+ }
+
+ _useFdMode = true;
+ }
+
+ // Native ClientHello peek for the deferred socket-bound flow AND the always-capture
+ // path (server session with options up front + CaptureClientHello switch on).
+ // Creates a socket-replay BIO on the fd, buffers a full TLS record via
+ // BioReadTlsFrame, parses via TlsFrameHelper, and populates ClientHelloInfo /
+ // TargetHostName from the SNI extension. In the deferred flow returns
+ // NeedsServerOptions so the caller resolves via SetContext; in the capture
+ // flow transfers the peek BIO to the pending SSL* and falls through to fd-mode.
+ // Either way the BIO stays alive so GetClientHelloBytes can span its retained
+ // prefix until Dispose().
+ partial void TryPeekClientHello(ref TlsOperationStatus? result)
+ {
+ if (_pendingFdSocket is null)
+ {
+ return;
+ }
+
+ // Deferred flow: caller hasn't resolved NeedsServerOptions yet - re-surface
+ // without re-reading the fd. Not reached on the capture-only path because we
+ // transition to fd-mode on the same TryPeekClientHello call that populates it.
+ if (_clientHelloInfo is not null && !_hasServerOptions)
+ {
+ result = TlsOperationStatus.NeedsTlsContext;
+ return;
+ }
+
+ if (_peekBio is null)
+ {
+ _peekBio = Interop.Ssl.BioNewSocketReplay(_pendingFdSocket, ReadOnlySpan.Empty);
+ if (_peekBio.IsInvalid)
+ {
+ _peekBio.Dispose();
+ _peekBio = null;
+ throw Interop.OpenSsl.CreateSslException(SR.net_ssl_read_bio_failed_error);
+ }
+ }
+
+ unsafe
+ {
+ int rc = Interop.Ssl.BioReadTlsFrame(_peekBio, out byte* framePtr, out int frameLen);
+ if (rc == 0)
+ {
+ // Need more bytes off the socket. Caller polls SelectRead and retries.
+ result = TlsOperationStatus.NeedMoreData;
+ return;
+ }
+ if (rc < 0)
+ {
+ throw new IOException(SR.net_ssl_read_bio_failed_error);
+ }
+
+ ReadOnlySpan frame = new ReadOnlySpan(framePtr, frameLen);
+ SslClientHelloInfo? parsed = TryParseClientHello(frame, out _);
+ if (parsed is null)
+ {
+ // TlsFrameHelper couldn't parse the record as a ClientHello.
+ throw new IOException(SR.net_io_decrypt);
+ }
+
+ _clientHelloInfo = parsed;
+ if (!string.IsNullOrEmpty(parsed.Value.ServerName))
+ {
+ _options.TargetHost = parsed.Value.ServerName;
+ }
+ }
+
+ if (!_hasServerOptions)
+ {
+ // Deferred / SNI-callback flow: caller inspects ClientHelloInfo and
+ // resolves via SetContext; OnServerContextSet then transfers the
+ // peek BIO to _options.PreallocatedReadBio.
+ result = TlsOperationStatus.NeedsTlsContext;
+ return;
+ }
+
+ // Always-capture flow: options are already supplied. Transfer the peek BIO
+ // to the pending SSL* now and drive the fast-path handshake step so the
+ // caller sees the same behavior as the pre-capture SSL_set_fd path.
+ // See OnServerContextSet: _peekBio stays referenced after transfer.
+ _options.PreallocatedReadBio = _peekBio;
+ _options.SocketHandle = _pendingFdSocket;
+ _pendingFdSocket = null;
+ _useFdMode = true;
+
+ TryFastHandshake(ref result);
+ }
+
+ // Called from TlsSession.Dispose. If the caller disposed the session before
+ // OnServerContextSet transferred the peek BIO to _options.PreallocatedReadBio,
+ // release it here so the native buffer / fd reference are freed.
+ partial void OnDispose()
+ {
+ _peekBio?.Dispose();
+ _peekBio = null;
+ }
+
+ // Returns a ReadOnlySpan over the socket-replay BIO's retained peek buffer.
+ // Valid as long as the SafeBioHandle is open. Consumers reach here through
+ // GetClientHelloBytes, which does ThrowIfDisposed() first.
+ partial void TryGetNativeClientHelloBytes(ref ReadOnlySpan bytes)
+ {
+ if (_peekBio is null || _peekBio.IsInvalid)
+ {
+ return;
+ }
+
+ unsafe
+ {
+ if (Interop.Ssl.BioGetPrefix(_peekBio, out byte* ptr, out int len) == 1 && len > 0)
+ {
+ bytes = new ReadOnlySpan(ptr, len);
+ }
+ }
+ }
+
+ partial void TryFastHandshake(ref TlsOperationStatus? result)
+ {
+ if (!_useFdMode)
+ {
+ return;
+ }
+
+ SafeSslHandle ssl = EnsureFdSslHandle();
+ int ret = Interop.Ssl.SslDoHandshake(ssl, out Interop.Ssl.SslErrorCode err);
+ if (ret == 1)
+ {
+ OnHandshakeCompleted();
+ result = TlsOperationStatus.Complete;
+ return;
+ }
+ result = MapSslError(err, "SSL_do_handshake");
+ }
+
+ partial void TryFastRead(Span buffer, ref int bytesRead, ref TlsOperationStatus? result)
+ {
+ if (!_useFdMode)
+ {
+ return;
+ }
+
+ if (buffer.IsEmpty)
+ {
+ result = TlsOperationStatus.Complete;
+ return;
+ }
+
+ SafeSslHandle ssl = (SafeSslHandle)_securityContext!;
+ int ret = Interop.Ssl.SslRead(ssl, ref MemoryMarshal.GetReference(buffer), buffer.Length, out Interop.Ssl.SslErrorCode err);
+ if (ret > 0)
+ {
+ bytesRead = ret;
+ result = TlsOperationStatus.Complete;
+ return;
+ }
+ result = MapSslError(err, "SSL_read");
+ }
+
+ partial void TryFastWrite(ReadOnlySpan buffer, ref int bytesWritten, ref TlsOperationStatus? result)
+ {
+ if (!_useFdMode)
+ {
+ return;
+ }
+
+ if (buffer.IsEmpty)
+ {
+ result = TlsOperationStatus.Complete;
+ return;
+ }
+
+ SafeSslHandle ssl = (SafeSslHandle)_securityContext!;
+ int ret = Interop.Ssl.SslWrite(ssl, ref MemoryMarshal.GetReference(buffer), buffer.Length, out Interop.Ssl.SslErrorCode err);
+ if (ret > 0)
+ {
+ bytesWritten = ret;
+ result = TlsOperationStatus.Complete;
+ return;
+ }
+ result = MapSslError(err, "SSL_write");
+ }
+
+ private SafeSslHandle EnsureFdSslHandle()
+ {
+ if (_securityContext is SafeSslHandle existing && !existing.IsInvalid)
+ {
+ return existing;
+ }
+ SafeSslHandle handle = Interop.OpenSsl.AllocateSslHandle(_options);
+ _securityContext = handle;
+ return handle;
+ }
+
+ private static TlsOperationStatus MapSslError(Interop.Ssl.SslErrorCode error, string op)
+ {
+ return error switch
+ {
+ Interop.Ssl.SslErrorCode.SSL_ERROR_WANT_READ => TlsOperationStatus.NeedMoreData,
+ Interop.Ssl.SslErrorCode.SSL_ERROR_WANT_WRITE => TlsOperationStatus.DestinationTooSmall,
+ Interop.Ssl.SslErrorCode.SSL_ERROR_ZERO_RETURN => TlsOperationStatus.Closed,
+ _ => throw new AuthenticationException($"OpenSSL {op} failed: {error}"),
+ };
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Stub.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Stub.cs
new file mode 100644
index 00000000000000..65724dc56ac719
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Stub.cs
@@ -0,0 +1,113 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Security.Authentication;
+using System.Security.Authentication.ExtendedProtection;
+using System.Security.Cryptography.X509Certificates;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Net.Sockets;
+
+namespace System.Net.Security
+{
+ ///
+ /// Stub implementation used on platforms where TlsSession is not yet supported.
+ /// All operations throw .
+ ///
+ [Experimental(Experimentals.LowLevelTlsDiagId, UrlFormat = Experimentals.SharedUrlFormat)]
+ public abstract class TlsSession : IDisposable
+ {
+ private protected TlsSession() { }
+
+ public bool IsHandshakeComplete => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ public bool HasPendingOutput => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ public string? TargetHostName
+ {
+ get => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ set => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ }
+ public SslClientHelloInfo? ClientHelloInfo => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ public int GetClientHelloLength() => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ public bool TryGetClientHelloBytes(Span destination, out int bytesWritten) => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ public SslProtocols NegotiatedProtocol => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ [System.CLSCompliant(false)]
+ public TlsCipherSuite NegotiatedCipherSuite => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ public SslApplicationProtocol NegotiatedApplicationProtocol => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public X509Certificate2? GetRemoteCertificate() =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public X509Certificate2Collection? GetRemoteCertificates() =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public SslPolicyErrors AcceptWithDefaultValidation() =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public void SetRemoteCertificateValidationResult(SslPolicyErrors errors) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public void SetContext(TlsContext context) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public void SetClientCertificateContext(SslStreamCertificateContext? context) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public IReadOnlyList? GetAcceptableIssuers() =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public X509Certificate2? LocalCertificate =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public ChannelBinding? GetChannelBinding(ChannelBindingKind kind) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public void Dispose() { }
+ }
+
+ [Experimental(Experimentals.LowLevelTlsDiagId, UrlFormat = Experimentals.SharedUrlFormat)]
+ public sealed class TlsBufferSession : TlsSession
+ {
+ public TlsBufferSession() => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Handshake(ReadOnlySpan source, Span destination, out int bytesConsumed, out int bytesWritten) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Write(ReadOnlySpan source, Span destination, out int bytesConsumed, out int bytesWritten) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Read(ReadOnlySpan source, Span destination, out int bytesConsumed, out int bytesWritten) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Shutdown(Span ciphertext, out int bytesWritten) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus DrainPendingOutput(Span ciphertext, out int bytesWritten) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus RequestClientCertificate(Span ciphertext, out int bytesWritten) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ }
+
+ [Experimental(Experimentals.LowLevelTlsDiagId, UrlFormat = Experimentals.SharedUrlFormat)]
+ public sealed class TlsSocketSession : TlsSession
+ {
+ public TlsSocketSession(SafeSocketHandle socket) => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public SafeSocketHandle Socket => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Handshake() =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Read(Span buffer, out int bytesRead) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Write(ReadOnlySpan buffer, out int bytesWritten) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Shutdown() =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus RequestClientCertificate() =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs
new file mode 100644
index 00000000000000..c80b311e91b80a
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs
@@ -0,0 +1,2260 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Buffers;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Authentication.ExtendedProtection;
+using System.Security.Cryptography.X509Certificates;
+// macOS PAL has two SafeDelete* derivatives (SecureTransport + Network.framework)
+// and surfaces the base type in ref parameters. Use the base type for the security-context
+// field on macOS so it lines up with the PAL ref signatures; other platforms keep the
+// derived SafeDeleteSslContext.
+#if TARGET_APPLE
+using TlsSecurityContext = System.Net.Security.SafeDeleteContext;
+#else
+using TlsSecurityContext = System.Net.Security.SafeDeleteSslContext;
+#endif
+
+namespace System.Net.Security
+{
+ ///
+ /// Non-blocking TLS state machine wrapper around the existing
+ /// . The caller owns I/O and drives ciphertext
+ /// in and out via byte spans. Supported on Linux/FreeBSD (OpenSSL) and
+ /// Windows (SChannel). Provides ,
+ /// , , and a pending-output queue.
+ ///
+ ///
+ ///
+ /// The session never performs any I/O. The caller drives ciphertext in/out
+ /// via byte spans. Any ciphertext the TLS layer needs to send (handshake
+ /// records, alerts, encrypted application data) is staged in an internal
+ /// pending-output buffer and drained via .
+ ///
+ ///
+ /// Contract: any operation may return
+ /// to indicate the caller must drain pending output before further progress
+ /// is possible. The session does not consume new input while pending output
+ /// is non-empty.
+ ///
+ ///
+ [Experimental(Experimentals.LowLevelTlsDiagId, UrlFormat = Experimentals.SharedUrlFormat)]
+ public abstract partial class TlsSession : IDisposable
+ {
+ // Matches StreamSizes.Default on Unix; conservative upper bound for a
+ // single TLS record's plaintext payload.
+ internal const int MaxRecordPlaintext = 16354;
+
+ // Nullable until SetContext is called. All operations that depend on a
+ // configured context validate this at entry.
+ private TlsContext? _context;
+ private SslAuthenticationOptions _options = null!;
+ private bool _ownsOptions;
+ private bool _hasServerOptions;
+ private TlsSecurityContext? _securityContext;
+
+ private byte[]? _pending;
+ private int _pendingOffset;
+ private int _pendingLength;
+
+ private byte[]? _decryptScratch;
+
+ private bool _isHandshakeComplete;
+ private bool _suppressInternalCertificateValidation;
+ private bool _externalValidationPending;
+ private bool _externalValidationResolved;
+ // Set by SetClientCertificateContext after a WantCredentials suspension; consumed by
+ // the next ProcessHandshake to allow an empty-input re-entry past the frame guard.
+ private bool _resumeAfterCredentials;
+ // Intermediate certs the peer sent (chain elements minus the leaf). The platform-built
+ // X509Chain itself is never surfaced to TlsSession callers; AcceptWithDefaultValidation
+ // rebuilds a fresh chain from this collection at validation time.
+ private X509Certificate2Collection? _externalRemoteCertificates;
+ private X509Certificate2? _externalPendingCert;
+ private Exception? _externalValidationFault;
+ private SslClientHelloInfo? _clientHelloInfo;
+ private byte[]? _clientHelloBytesBuffered;
+ // Session-local credentials handle. Non-null once SetClientCertificateContext
+ // has been called; from that point on, this session's PAL calls route through
+ // ActiveCredentialsRef() and never touch the shared TlsContext.CredentialsHandle.
+ // Disposed when the session is disposed.
+ private SafeFreeCredentials? _sessionCredentialsHandle;
+ private bool _disposed;
+ private SslConnectionInfo _connectionInfo;
+ private X509Certificate2? _remoteCertificate;
+ private int _headerSize;
+ private int _trailerSize;
+ private int _maxDataSize = MaxRecordPlaintext;
+
+ // Socket-bound mode (optional). When set, the session performs its own
+ // non-blocking I/O via Handshake/Read/Write. The session takes ownership
+ // of the supplied socket handle and disposes it with the session.
+ private SafeSocketHandle? _socketHandle;
+ private Socket? _socket;
+ private byte[]? _socketInBuf;
+ private int _socketInUsed;
+
+ private protected TlsSession()
+ {
+ }
+
+ // Called by TlsSocketSession's constructor to bind a socket handle before the
+ // session receives any TlsContext. The socket is taken to ownership and disposed
+ // with the session. Platforms with a native fd-binding fast path (OpenSSL) take
+ // the socket directly; otherwise the socket is wrapped in a managed Socket for
+ // the buffered I/O path.
+ internal void AttachSocket(SafeSocketHandle socket)
+ {
+ Debug.Assert(socket != null);
+ _socketHandle = socket;
+
+ bool nativeBindingEnabled = false;
+ EnableNativeSocketBinding(socket, ref nativeBindingEnabled);
+ if (!nativeBindingEnabled)
+ {
+ _socket = new Socket(socket);
+ }
+ }
+
+ internal SafeSocketHandle? SocketHandle => _socketHandle;
+
+ private void InitializeFromContext(TlsContext context)
+ {
+ Debug.Assert(_context is null);
+ _context = context;
+ _ownsOptions = !context.ShareOptions;
+ _options = context.CreateSessionOptions();
+ _hasServerOptions = context.TemplateHasServerOptions;
+ OnContextInitialized();
+ }
+
+ internal virtual void OnContextInitialized()
+ {
+ }
+
+
+ // ── State ─────────────────────────────────────────────────────────
+
+ public bool IsHandshakeComplete => _isHandshakeComplete;
+
+ public bool HasPendingOutput => _pendingLength > 0;
+
+ public string? TargetHostName
+ {
+ get { ThrowIfContextNotSet(); return _options.TargetHost; }
+ set { ThrowIfContextNotSet(); _options.TargetHost = value ?? string.Empty; }
+ }
+
+ public SslProtocols NegotiatedProtocol
+ {
+ get
+ {
+ if (!_isHandshakeComplete || _connectionInfo.Protocol == 0)
+ {
+ return SslProtocols.None;
+ }
+
+ // On Windows (SChannel), the reported protocol value carries
+ // client/server direction bits (SP_PROT_TLS1_2_CLIENT == 0x800,
+ // SP_PROT_TLS1_2_SERVER == 0x400, etc.). Canonicalize to the
+ // managed SslProtocols enum values, matching SslStream.
+ SslProtocols proto = (SslProtocols)_connectionInfo.Protocol;
+ SslProtocols ret = SslProtocols.None;
+#pragma warning disable 0618
+ if ((proto & SslProtocols.Ssl2) != 0) ret |= SslProtocols.Ssl2;
+ if ((proto & SslProtocols.Ssl3) != 0) ret |= SslProtocols.Ssl3;
+#pragma warning restore
+#pragma warning disable SYSLIB0039
+ if ((proto & SslProtocols.Tls) != 0) ret |= SslProtocols.Tls;
+ if ((proto & SslProtocols.Tls11) != 0) ret |= SslProtocols.Tls11;
+#pragma warning restore SYSLIB0039
+ if ((proto & SslProtocols.Tls12) != 0) ret |= SslProtocols.Tls12;
+ if ((proto & SslProtocols.Tls13) != 0) ret |= SslProtocols.Tls13;
+ return ret;
+ }
+ }
+
+ [System.CLSCompliant(false)]
+ public TlsCipherSuite NegotiatedCipherSuite =>
+ _isHandshakeComplete ? (TlsCipherSuite)_connectionInfo.TlsCipherSuite : default;
+
+ public SslApplicationProtocol NegotiatedApplicationProtocol
+ {
+ get
+ {
+ if (!_isHandshakeComplete || _connectionInfo.ApplicationProtocol == null)
+ {
+ return default;
+ }
+ return new SslApplicationProtocol(_connectionInfo.ApplicationProtocol);
+ }
+ }
+
+ public X509Certificate2? GetRemoteCertificate()
+ {
+ if (_remoteCertificate is not null)
+ {
+ return _remoteCertificate;
+ }
+
+ if (_externalPendingCert is not null)
+ {
+ return _externalPendingCert;
+ }
+
+ if (_securityContext == null || _securityContext.IsInvalid)
+ {
+ return null;
+ }
+ return CertificateValidationPal.GetRemoteCertificate(_securityContext);
+ }
+
+ ///
+ /// Returns the intermediate certificates the peer sent alongside its leaf certificate
+ /// (the leaf itself is available via ), or null
+ /// if no intermediates were received. Only meaningful while the session is awaiting an
+ /// external validation result (after returned
+ /// ). The certificates are
+ /// owned by the session and disposed when the session is disposed or when the validation
+ /// result is recorded; callers that need to retain them must clone the instances.
+ ///
+ public X509Certificate2Collection? GetRemoteCertificates()
+ {
+ ThrowIfDisposed();
+ return _externalRemoteCertificates;
+ }
+
+ ///
+ /// Runs the same validation performs (default chain
+ /// build plus any user-supplied
+ /// on the underlying options), records the result on the session, and returns
+ /// the effective . Intended for callers that want
+ /// -compatible semantics without writing their own
+ /// validation logic.
+ ///
+ ///
+ /// Must be called only after returned
+ /// and before
+ /// is called.
+ ///
+ public SslPolicyErrors AcceptWithDefaultValidation()
+ {
+ ThrowIfDisposed();
+ if (!_externalValidationPending)
+ {
+ throw new InvalidOperationException(
+ $"{nameof(AcceptWithDefaultValidation)} can only be called when certificate validation is pending.");
+ }
+
+ // Build a fresh X509Chain locally and seed it with the peer-sent intermediates.
+ // The chain instance is never exposed to TlsSession callers; once validation is
+ // recorded it is disposed in SetRemoteCertificateValidationResult below.
+ X509Chain chain = new X509Chain();
+ if (_externalRemoteCertificates is { Count: > 0 } intermediates)
+ {
+ chain.ChainPolicy.ExtraStore.AddRange(intermediates);
+ }
+
+ ProtocolToken alertToken = default;
+ SslPolicyErrors sslPolicyErrors;
+ bool ok;
+ try
+ {
+ // Pass _externalPendingCert as the candidate cert and an empty _remoteCertificate slot.
+ // VerifyRemoteCertificateCore assigns the slot to the candidate on success; the renegotiation
+ // shortcut at the top of that method would otherwise dispose our cert if the slot were already
+ // populated with the same instance.
+ ok = SslStream.VerifyRemoteCertificateCore(
+ this,
+ _options,
+ _securityContext,
+ ref _remoteCertificate,
+ ref _connectionInfo,
+ _externalPendingCert,
+ chain,
+ trust: null,
+ ref alertToken,
+ out sslPolicyErrors,
+ out _);
+ }
+ finally
+ {
+ chain.Dispose();
+ }
+
+ // A user RemoteCertificateValidationCallback can reject an otherwise-clean chain
+ // by returning false with sslPolicyErrors == None. Synthesize a non-None failure
+ // so SetRemoteCertificateValidationResult takes the reject branch instead of accepting.
+ if (!ok && sslPolicyErrors == SslPolicyErrors.None)
+ {
+ sslPolicyErrors = SslPolicyErrors.RemoteCertificateChainErrors;
+ }
+
+ // On success VerifyRemoteCertificateCore set _remoteCertificate = _externalPendingCert, so
+ // SetRemoteCertificateValidationResult below leaves it alone. On failure we must dispose the
+ // pending cert ourselves because no one adopted it.
+ SetRemoteCertificateValidationResult(ok ? SslPolicyErrors.None : sslPolicyErrors);
+ return sslPolicyErrors;
+ }
+
+ ///
+ /// Records the caller's external certificate-validation result.
+ /// means accept; any other value causes
+ /// subsequent calls to , ,
+ /// and to throw .
+ /// Must be called exactly once between
+ /// and the next
+ /// session operation.
+ ///
+ public void SetRemoteCertificateValidationResult(SslPolicyErrors errors)
+ {
+ ThrowIfDisposed();
+ if (!_externalValidationPending)
+ {
+ throw new InvalidOperationException(
+ $"{nameof(SetRemoteCertificateValidationResult)} can only be called when certificate validation is pending.");
+ }
+
+ _externalValidationPending = false;
+ _externalValidationResolved = true;
+
+#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
+ // OpenSSL 3.0+ retry-verify path: the handshake paused inside the CertVerifyCallback.
+ // Push the verdict to the SafeSslHandle so the next SSL_do_handshake call (driven by
+ // the caller's next ProcessHandshake) re-invokes the callback and either accepts the
+ // peer cert (Finished is emitted) or rejects it (a fatal alert is emitted).
+ PushExternalValidationVerdictToPalIfRetryVerify(errors);
+#endif
+
+ if (errors == SslPolicyErrors.None)
+ {
+ // Caller accepted. Promote the pending cert to the canonical remote-cert slot
+ // (unless AcceptWithDefaultValidation already did so).
+ if (_remoteCertificate is null)
+ {
+ _remoteCertificate = _externalPendingCert;
+ _externalPendingCert = null;
+ }
+ else
+ {
+ // VerifyRemoteCertificateCore adopted the cert into _remoteCertificate. Drop our copy.
+ _externalPendingCert = null;
+ }
+ }
+ else
+ {
+ // Post-hoc rejection (handshake already wire-complete on OpenSSL 1.1.x or Schannel):
+ // surface the fault immediately so subsequent Encrypt/Decrypt throw. For the
+ // retry-verify path the handshake is still incomplete and the fault is set when
+ // ProcessHandshake drives SSL_do_handshake to failure (so any pending alert bytes
+ // are drained to the caller first).
+ if (_isHandshakeComplete)
+ {
+ _externalValidationFault = new AuthenticationException(SR.net_ssl_io_cert_validation);
+ }
+
+ // VerifyRemoteCertificateCore assigns _remoteCertificate to the candidate before it
+ // knows whether the chain validates, so on the reject path the rejected leaf is sitting
+ // in the canonical slot. Drop it so GetRemoteCertificate cannot surface a cert the caller
+ // explicitly refused. Either _remoteCertificate or _externalPendingCert owns it, not both.
+ if (_remoteCertificate is not null && ReferenceEquals(_remoteCertificate, _externalPendingCert))
+ {
+ _remoteCertificate = null;
+ }
+ else
+ {
+ _remoteCertificate?.Dispose();
+ _remoteCertificate = null;
+ }
+ _externalPendingCert?.Dispose();
+ _externalPendingCert = null;
+ }
+
+ DisposeExternalRemoteCertificates();
+ }
+
+#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
+ // Client-side only path. When CertVerifyCallback paused the handshake via
+ // SSL_set_retry_verify, RetryVerifyAttempted is set on the SafeSslHandle. Stamp
+ // the caller's verdict onto the handle so the next SSL_do_handshake (driven by
+ // the caller's next ProcessHandshake) re-enters the callback and either accepts
+ // the peer cert or emits a fatal alert. No-op on server sessions and on 1.1.x
+ // where CertVerifyCallback took the accept-and-defer branch instead of retrying.
+ private void PushExternalValidationVerdictToPalIfRetryVerify(SslPolicyErrors errors)
+ {
+ if (_securityContext is not Microsoft.Win32.SafeHandles.SafeSslHandle sslHandle ||
+ !sslHandle.RetryVerifyAttempted)
+ {
+ return;
+ }
+
+ sslHandle.ExternalValidationAccepted = errors == SslPolicyErrors.None;
+ }
+#endif
+
+ ///
+ /// Server-side only. The parsed ClientHello information, populated once the
+ /// ClientHello has been received and stays populated for the lifetime of the
+ /// session. Returns before the ClientHello arrives,
+ /// on client-side sessions, and on server sessions where ClientHello capture
+ /// was disabled via the System.Net.Security.CaptureClientHello AppContext
+ /// switch AND options were supplied at creation time.
+ ///
+ public SslClientHelloInfo? ClientHelloInfo
+ {
+ get
+ {
+ ThrowIfDisposed();
+ return _clientHelloInfo;
+ }
+ }
+
+ ///
+ /// Server-side only. Returns the number of bytes in the captured raw ClientHello
+ /// record (5-byte TLS record header plus the ClientHello handshake message), or
+ /// 0 if unavailable. Callers use this to size a destination buffer for
+ /// .
+ ///
+ ///
+ /// The ClientHello is only captured on server-side sessions and requires the
+ /// System.Net.Security.CaptureClientHello AppContext switch to be enabled
+ /// (default true). Returns 0 on client-side sessions, before the ClientHello has
+ /// been received, or when capture has been disabled.
+ ///
+ public int GetClientHelloLength()
+ {
+ ThrowIfDisposed();
+
+ ReadOnlySpan native = default;
+ TryGetNativeClientHelloBytes(ref native);
+ if (!native.IsEmpty)
+ {
+ return native.Length;
+ }
+
+ return _clientHelloBytesBuffered?.Length ?? 0;
+ }
+
+ ///
+ /// Server-side only. Copies the captured raw ClientHello record into
+ /// . Returns when the full
+ /// record was written; if the destination is too small
+ /// or the ClientHello is not available.
+ ///
+ /// Buffer that receives the ClientHello bytes.
+ /// Number of bytes copied. Zero when the method returns false.
+ public bool TryGetClientHelloBytes(Span destination, out int bytesWritten)
+ {
+ ThrowIfDisposed();
+
+ ReadOnlySpan source = default;
+ TryGetNativeClientHelloBytes(ref source);
+ if (source.IsEmpty)
+ {
+ if (_clientHelloBytesBuffered is null)
+ {
+ bytesWritten = 0;
+ return false;
+ }
+ source = _clientHelloBytesBuffered;
+ }
+
+ if (destination.Length < source.Length)
+ {
+ bytesWritten = 0;
+ return false;
+ }
+
+ source.CopyTo(destination);
+ bytesWritten = source.Length;
+ return true;
+ }
+
+ ///
+ /// Assigns a to this session. Must be called at least
+ /// once before or its socket-bound
+ /// equivalent can make forward progress. May also be called on a server-side
+ /// session that suspended with
+ /// to steer it onto the resolved per-tenant context.
+ ///
+ /// A fully-configured .
+ /// Thrown when is null.
+ ///
+ /// Thrown when supplying a resolved context after
+ /// and the passed context is
+ /// not server-side.
+ ///
+ ///
+ /// Thrown when the session already has a context and is not currently awaiting
+ /// server options (i.e., the caller tried to swap a context that was already
+ /// fully configured).
+ ///
+ public void SetContext(TlsContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ThrowIfDisposed();
+
+ if (_context is null)
+ {
+ InitializeFromContext(context);
+ return;
+ }
+
+ if (!_context!.IsServer)
+ {
+ throw new InvalidOperationException("SetContext can only be called on a server-side session.");
+ }
+ if (!context.IsServer)
+ {
+ throw new ArgumentException("TlsContext must be server-side.", nameof(context));
+ }
+ if (_hasServerOptions)
+ {
+ throw new InvalidOperationException("Server options were already supplied when the TlsContext was created.");
+ }
+ if (_clientHelloInfo is null)
+ {
+ throw new InvalidOperationException("SetContext can only be called after Handshake returned NeedsTlsContext.");
+ }
+
+ // Ask the supplied context for a session-options bag — this allocates its
+ // long-lived SSL_CTX (if not already) and stamps PreallocatedSslContext on
+ // the returned bag. Copy those fields (including PreallocatedSslContext) into
+ // our session's options so subsequent AllocateSslHandle picks up the passed
+ // context's SSL_CTX instead of falling back to the per-session cache path.
+ SslAuthenticationOptions serverOpts = context.CreateSessionOptions();
+ _options.CopyFrom(serverOpts);
+#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
+ _options.PreallocatedSslContext = serverOpts.PreallocatedSslContext;
+#endif
+
+ _hasServerOptions = true;
+
+ // The per-tenant options differ from the bootstrap context's template, so
+ // credentials must be session-local. Otherwise EnsureCredentialsAcquired
+ // would stamp this session's SChannel cred handle into the shared bootstrap
+ // TlsContext.CredentialsHandle, and every subsequent session on the same
+ // bootstrap would inherit those credentials regardless of which tenant it
+ // resolved to (SChannel-only; OpenSSL routes per-tenant SSL_CTX via
+ // PreallocatedSslContext on the session-local options bag). Acquire eagerly
+ // so any AcquireCredentialsHandle failure surfaces from SetContext,
+ // not from an opaque PAL call downstream.
+ _sessionCredentialsHandle?.Dispose();
+ _sessionCredentialsHandle = SslStreamPal.AcquireCredentialsHandle(_options, false);
+
+ OnServerContextSet();
+ }
+
+ ///
+ /// Client-side only. Supplies the certificate context the session should send
+ /// in response to the server's CertificateRequest, or to
+ /// decline. Intended to resolve a session suspended on
+ /// : callers that need to
+ /// fetch a certificate from an out-of-process source (e.g. a key vault) do so
+ /// outside the session, then resume the handshake. May also be called before
+ /// the first handshake call to seed the client credential when the
+ /// was created without one.
+ ///
+ ///
+ /// Thrown on a server-side session, or before has been
+ /// called.
+ ///
+ public void SetClientCertificateContext(SslStreamCertificateContext? context)
+ {
+ ThrowIfDisposed();
+ ThrowIfContextNotSet();
+
+ if (_context!.IsServer)
+ {
+ throw new InvalidOperationException("SetClientCertificateContext can only be called on a client-side session.");
+ }
+ _options.CertificateContext = context;
+
+ // Drop the cached credentials handle (acquired without a client cert) so the
+ // next Handshake re-acquires with the supplied context.
+ _context!.CredentialsHandle?.Dispose();
+ _context!.CredentialsHandle = null;
+ _resumeAfterCredentials = true;
+ }
+
+ ///
+ /// Client-side only. Returns the distinguished names of the certificate authorities
+ /// the server listed in its TLS 1.2 CertificateRequest or TLS 1.3
+ /// certificate_authorities extension. Intended to be called while the session
+ /// is suspended on so the caller can
+ /// pick a client certificate that chains to one of the listed CAs. Returns
+ /// when no security context exists yet, when the peer sent no
+ /// hints, or on a server-side session.
+ ///
+ public IReadOnlyList? GetAcceptableIssuers()
+ {
+ ThrowIfDisposed();
+
+ if (_context is null || _context.IsServer || _securityContext is null)
+ {
+ return null;
+ }
+
+ string[] issuers = CertificateValidationPal.GetRequestCertificateAuthorities(_securityContext);
+ return issuers.Length == 0 ? null : issuers;
+ }
+
+ private void ThrowIfPendingExternalValidation()
+ {
+ if (_externalValidationFault is not null)
+ {
+ throw _externalValidationFault;
+ }
+ if (_externalValidationPending)
+ {
+ throw new InvalidOperationException(
+ "External certificate validation result has not been recorded. Call SetRemoteCertificateValidationResult or AcceptWithDefaultValidation first.");
+ }
+ }
+
+ private void DisposeExternalRemoteCertificates()
+ {
+ X509Certificate2Collection? certs = _externalRemoteCertificates;
+ _externalRemoteCertificates = null;
+ if (certs is null)
+ {
+ return;
+ }
+ foreach (X509Certificate2 c in certs)
+ {
+ c.Dispose();
+ }
+ }
+
+ ///
+ /// Returns the local certificate sent to the peer, or null if no
+ /// local certificate was negotiated. For a server session this is the
+ /// server certificate; for a client session this is the client
+ /// certificate selected during handshake (which may be null if
+ /// the server did not request a client certificate or the client did
+ /// not supply one).
+ ///
+ public X509Certificate2? LocalCertificate
+ {
+ get
+ {
+ ThrowIfDisposed();
+ if (_context!.IsServer)
+ {
+ return _options.CertificateContext?.TargetCertificate;
+ }
+
+ if (_securityContext == null || _securityContext.IsInvalid)
+ {
+ return null;
+ }
+
+ if (!CertificateValidationPal.IsLocalCertificateUsed(ActiveCredentialsRef(), _securityContext))
+ {
+ return null;
+ }
+
+ return _options.CertificateContext?.TargetCertificate;
+ }
+ }
+
+ ///
+ /// Returns a for the requested
+ /// derived from the current TLS session, or
+ /// null if the binding is unavailable (e.g. handshake not yet
+ /// complete, or unsupported binding kind).
+ ///
+ public ChannelBinding? GetChannelBinding(ChannelBindingKind kind)
+ {
+ ThrowIfDisposed();
+ if (_securityContext == null || _securityContext.IsInvalid)
+ {
+ return null;
+ }
+ return SslStreamPal.QueryContextChannelBinding(_securityContext, kind);
+ }
+
+ // ── Handshake ─────────────────────────────────────────────────────
+
+ private protected TlsOperationStatus HandshakeBufferedCore(
+ ReadOnlySpan input,
+ Span output,
+ out int bytesConsumed,
+ out int bytesWritten)
+ {
+ ThrowIfDisposed();
+ bytesConsumed = 0;
+ bytesWritten = 0;
+
+ if (_externalValidationFault is not null)
+ {
+ throw _externalValidationFault;
+ }
+
+ if (_externalValidationPending)
+ {
+ return TlsOperationStatus.NeedsCertificateValidation;
+ }
+
+ if (_clientHelloInfo is not null && !_hasServerOptions)
+ {
+ // The caller previously saw NeedsServerOptions but hasn't supplied options yet.
+ return TlsOperationStatus.NeedsTlsContext;
+ }
+
+ if (_isHandshakeComplete)
+ {
+ // Once the caller has resolved external validation, subsequent
+ // ProcessHandshake calls on an already-complete session are a
+ // no-op signal that the handshake is done (one-call window).
+ if (_externalValidationResolved)
+ {
+ return TlsOperationStatus.Complete;
+ }
+
+ throw new InvalidOperationException("Handshake has already completed.");
+ }
+
+ // Drain pending first; do not consume new input while output is owed.
+ if (_pendingLength > 0)
+ {
+ bytesWritten = DrainTo(output);
+ return _pendingLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Complete;
+ }
+
+ // The PAL state machine — SChannel in particular — must only be handed
+ // complete TLS records. SChannel's PAL wrapper reports consumed=input.Length
+ // when it returns SEC_E_INCOMPLETE_MESSAGE, which would silently swallow
+ // bytes it actually still needs. OpenSSL's BIO accepts partial bytes, but
+ // pre-checking the frame here costs nothing extra and keeps the state
+ // machine identical across platforms.
+ //
+ // The only call that legitimately runs with empty input is the very first
+ // client-side ISC, which produces the ClientHello, or a client resume after
+ // SetClientCertificateContext resolved a prior WantCredentials suspension.
+ bool isInitialClientCall = !_context!.IsServer && _securityContext is null;
+ bool isCredentialResume = _resumeAfterCredentials;
+ _resumeAfterCredentials = false;
+ if (!isInitialClientCall && !isCredentialResume)
+ {
+ if (input.Length < TlsFrameHelper.HeaderSize)
+ {
+ return TlsOperationStatus.NeedMoreData;
+ }
+
+ TlsFrameHeader frameHeader = default;
+ if (!TlsFrameHelper.TryGetFrameHeader(input, ref frameHeader))
+ {
+ throw new IOException(SR.net_io_decrypt);
+ }
+
+ if (input.Length < frameHeader.Length)
+ {
+ return TlsOperationStatus.NeedMoreData;
+ }
+ }
+
+ ProtocolToken token = default;
+ token.RentBuffer = true;
+ try
+ {
+ if (_context!.IsServer)
+ {
+ // Parse and capture the ClientHello managed-side so the ClientHelloInfo /
+ // TargetHostName / GetClientHelloBytes surface is consistent across paths.
+ // We check on every call while _clientHelloBytesBuffered is null because the
+ // first ProcessHandshake call may pass only a partial CH record - OpenSSL will
+ // allocate _securityContext even on partial input and return WantRead, so we
+ // can't rely on _securityContext being null as our re-entry gate.
+ if (_clientHelloBytesBuffered is null)
+ {
+ SslClientHelloInfo? parsed = TryParseClientHello(input, out int frameLength);
+ if (parsed is not null)
+ {
+ _clientHelloInfo = parsed;
+ if (!string.IsNullOrEmpty(parsed.Value.ServerName))
+ {
+ _options.TargetHost = parsed.Value.ServerName;
+ }
+ if (frameLength > 0 && frameLength <= input.Length)
+ {
+ _clientHelloBytesBuffered = input.Slice(0, frameLength).ToArray();
+ }
+ else
+ {
+ throw new InvalidOperationException($"CAPTURE-DEBUG: fL={frameLength} iL={input.Length}");
+ }
+ }
+ else if (_securityContext is null)
+ {
+ // No CH parse-able yet and no PAL context yet - wait for more bytes.
+ return TlsOperationStatus.NeedMoreData;
+ }
+ }
+
+ // On the very first server-side call, inspect the incoming
+ // ClientHello to surface SNI (TargetHost) and, if the caller
+ // supplied a ServerCertificateSelectionCallback, resolve the
+ // server certificate from it before AllocateSslHandle runs.
+ if (_securityContext is null)
+ {
+ if (!_hasServerOptions)
+ {
+ // Deferred / SNI-callback flow: caller resolves via SetContext.
+ // Leave input unconsumed; the caller re-feeds the same bytes on resume.
+ return TlsOperationStatus.NeedsTlsContext;
+ }
+
+ bool needsCertResolution =
+ _options.CertificateContext is null &&
+ _options.ServerCertSelectionDelegate is not null;
+
+ if (needsCertResolution && !ResolveServerCertificateFromClientHello(input))
+ {
+ // Need more bytes to parse the ClientHello (and run the
+ // ServerCertificateSelectionCallback).
+ return TlsOperationStatus.NeedMoreData;
+ }
+ }
+
+ EnsureCredentialsAcquired();
+
+ token = SslStreamPal.AcceptSecurityContext(
+ ref ActiveCredentialsRef(),
+ ref _securityContext,
+ input,
+ out bytesConsumed,
+ _options);
+ }
+ else
+ {
+ EnsureCredentialsAcquired();
+
+ string hostName = TargetHostNameHelper.NormalizeHostName(_options.TargetHost);
+ token = SslStreamPal.InitializeSecurityContext(
+ ref ActiveCredentialsRef(),
+ ref _securityContext,
+ hostName,
+ input,
+ out bytesConsumed,
+ _options);
+ }
+
+ // Stage any handshake bytes the PAL produced.
+ if (token.Size > 0)
+ {
+ Debug.Assert(token.Payload != null);
+ AppendPending(new ReadOnlySpan(token.Payload, 0, token.Size));
+ }
+
+ // Server-side ALPN selection ceremony (SChannel and SecureTransport).
+ // After parsing the ClientHello the PAL pauses and asks the caller to
+ // pick the application protocol before resuming. We re-enter ASC with
+ // an empty input so the PAL can generate the ServerHello carrying the
+ // selected ALPN value.
+ if (token.Status.ErrorCode == SecurityStatusPalErrorCode.HandshakeStarted)
+ {
+ ReadOnlySpan rawAlpn = ReadOnlySpan.Empty;
+ TlsFrameHelper.TlsFrameInfo frameInfo = default;
+ if (TlsFrameHelper.TryGetFrameInfo(input, ref frameInfo,
+ TlsFrameHelper.ProcessingOptions.ApplicationProtocol | TlsFrameHelper.ProcessingOptions.RawApplicationProtocol) &&
+ frameInfo.RawApplicationProtocols is byte[] rawAlpnBytes)
+ {
+ rawAlpn = rawAlpnBytes;
+ }
+
+ SecurityStatusPal selStatus = SslStreamPal.SelectApplicationProtocol(
+ _context!.CredentialsHandle,
+ _securityContext!,
+ _options,
+ rawAlpn);
+
+ if (selStatus.ErrorCode != SecurityStatusPalErrorCode.OK)
+ {
+ throw new AuthenticationException(SR.net_auth_SSPI, selStatus.Exception);
+ }
+
+ token.ReleasePayload();
+
+ if (_context!.IsServer)
+ {
+ token = SslStreamPal.AcceptSecurityContext(
+ ref ActiveCredentialsRef(),
+ ref _securityContext,
+ ReadOnlySpan.Empty,
+ out _,
+ _options);
+ }
+ else
+ {
+ string hostName = TargetHostNameHelper.NormalizeHostName(_options.TargetHost);
+ token = SslStreamPal.InitializeSecurityContext(
+ ref ActiveCredentialsRef(),
+ ref _securityContext,
+ hostName,
+ ReadOnlySpan.Empty,
+ out _,
+ _options);
+ }
+
+ if (token.Size > 0)
+ {
+ Debug.Assert(token.Payload != null);
+ AppendPending(new ReadOnlySpan(token.Payload, 0, token.Size));
+ }
+ }
+
+ if (token.Failed &&
+ token.Status.ErrorCode != SecurityStatusPalErrorCode.CredentialsNeeded &&
+ token.Status.ErrorCode != SecurityStatusPalErrorCode.CertValidationNeeded)
+ {
+ Exception authExc = new AuthenticationException(SR.net_auth_SSPI, token.GetException());
+
+ // OpenSSL queued a TLS alert in the BIO during the failing SSL_do_handshake
+ // (e.g. bad_certificate after the client-side retry-verify callback rejected
+ // the peer). Drain the alert to the caller's output buffer before throwing so
+ // the peer observes an AuthenticationException instead of a connection reset.
+ // The fault is re-raised on the next ProcessHandshake call once the queue is
+ // empty. Only fires on the client path today; server-side never reaches this
+ // branch for external-validation reasons because CertVerifyCallback
+ // accepts-and-defers (see gating in Interop.OpenSsl.CertVerifyCallback).
+ if (_pendingLength > 0)
+ {
+ bytesWritten = DrainTo(output);
+ _externalValidationFault = authExc;
+ return TlsOperationStatus.DestinationTooSmall;
+ }
+
+ throw authExc;
+ }
+
+ bool done = token.Status.ErrorCode == SecurityStatusPalErrorCode.OK;
+ bool needsCredentials = token.Status.ErrorCode == SecurityStatusPalErrorCode.CredentialsNeeded;
+ bool needsCertValidation = token.Status.ErrorCode == SecurityStatusPalErrorCode.CertValidationNeeded;
+
+ if (done)
+ {
+ OnHandshakeCompleted();
+ }
+ else if (needsCertValidation)
+ {
+ // PAL paused mid-handshake awaiting external certificate validation.
+ // Capture the peer cert + chain so the caller can validate, then return
+ // NeedsCertificateValidation. Not used by the current OpenSSL or SChannel
+ // paths but kept as a generic suspension hook.
+ CaptureRemoteCertificateForExternalValidation();
+ }
+
+ if (_pendingLength > 0)
+ {
+ bytesWritten = DrainTo(output);
+ if (_pendingLength > 0)
+ {
+ return TlsOperationStatus.DestinationTooSmall;
+ }
+ }
+
+ if (done)
+ {
+ return _externalValidationPending
+ ? TlsOperationStatus.NeedsCertificateValidation
+ : TlsOperationStatus.Complete;
+ }
+
+ if (needsCertValidation)
+ {
+ return TlsOperationStatus.NeedsCertificateValidation;
+ }
+
+ if (needsCredentials)
+ {
+ return TlsOperationStatus.CertificateRequested;
+ }
+
+ // SChannel consumes one TLS record per AcceptSecurityContext/
+ // InitializeSecurityContext call (OpenSSL typically consumes the
+ // whole input via the BIO). When the PAL accepted bytes but the
+ // caller still has more buffered, return Complete so the driver
+ // re-enters us with the remainder instead of blocking on a network
+ // read the peer will never satisfy (e.g. server seeing CKE+CCS+
+ // Finished in one TCP read during a TLS 1.2 handshake).
+ if (bytesConsumed > 0 && bytesConsumed < input.Length)
+ {
+ return TlsOperationStatus.Complete;
+ }
+
+ return TlsOperationStatus.NeedMoreData;
+ }
+ finally
+ {
+ token.ReleasePayload();
+ }
+ }
+
+ private protected TlsOperationStatus WriteBufferedCore(
+ ReadOnlySpan plaintext,
+ Span ciphertext,
+ out int bytesConsumed,
+ out int bytesWritten)
+ {
+ ThrowIfDisposed();
+ ThrowIfPendingExternalValidation();
+ bytesConsumed = 0;
+ bytesWritten = 0;
+
+ if (!_isHandshakeComplete)
+ {
+ throw new InvalidOperationException("Handshake has not yet completed.");
+ }
+
+ if (_pendingLength > 0)
+ {
+ bytesWritten = DrainTo(ciphertext);
+ return _pendingLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Complete;
+ }
+
+ if (plaintext.IsEmpty)
+ {
+ return TlsOperationStatus.Complete;
+ }
+
+ int chunk = Math.Min(plaintext.Length, _maxDataSize);
+ byte[] rented = ArrayPool.Shared.Rent(chunk);
+ try
+ {
+ plaintext.Slice(0, chunk).CopyTo(rented);
+
+ ProtocolToken token = SslStreamPal.EncryptMessage(
+ _securityContext!,
+ new ReadOnlyMemory(rented, 0, chunk),
+ _headerSize,
+ _trailerSize);
+
+ try
+ {
+ if (token.Status.ErrorCode != SecurityStatusPalErrorCode.OK)
+ {
+ throw new IOException(SR.net_io_encrypt, SslStreamPal.GetException(token.Status));
+ }
+
+ bytesConsumed = chunk;
+
+ if (token.Size > 0)
+ {
+ Debug.Assert(token.Payload != null);
+ AppendPending(new ReadOnlySpan(token.Payload, 0, token.Size));
+ }
+ }
+ finally
+ {
+ token.ReleasePayload();
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(rented);
+ }
+
+ bytesWritten = DrainTo(ciphertext);
+ return _pendingLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Complete;
+ }
+
+ // ── Decrypt ───────────────────────────────────────────────────────
+
+ private protected TlsOperationStatus ReadBufferedCore(
+ ReadOnlySpan ciphertext,
+ Span plaintext,
+ out int bytesConsumed,
+ out int bytesWritten)
+ {
+ ThrowIfDisposed();
+ ThrowIfPendingExternalValidation();
+ bytesConsumed = 0;
+ bytesWritten = 0;
+
+ if (!_isHandshakeComplete)
+ {
+ throw new InvalidOperationException("Handshake has not yet completed.");
+ }
+
+ if (_pendingLength > 0)
+ {
+ // Caller must drain before we accept new input.
+ return TlsOperationStatus.DestinationTooSmall;
+ }
+
+ // Need at least a frame header. If the caller didn't provide a full frame, the PAL
+ // may still have plaintext buffered internally — ciphertext absorbed by OpenSSL's
+ // BIO during ProcessHandshake (e.g. the peer coalesced its Finished with the first
+ // app-data record into one TCP segment) or a record consumed but not yet decrypted
+ // by a prior Decrypt call. On platforms whose PAL maintains such a buffer, probe it
+ // with an empty input before asking the caller for more wire bytes; otherwise the
+ // session deadlocks waiting on data the peer already sent.
+ if (ciphertext.Length < TlsFrameHelper.HeaderSize)
+ {
+ return TryDrainBufferedPlaintext(plaintext, out bytesWritten);
+ }
+
+ TlsFrameHeader header = default;
+ if (!TlsFrameHelper.TryGetFrameHeader(ciphertext, ref header))
+ {
+ throw new IOException(SR.net_io_decrypt);
+ }
+
+ int frameSize = header.Length;
+ if (ciphertext.Length < frameSize)
+ {
+ return TryDrainBufferedPlaintext(plaintext, out bytesWritten);
+ }
+
+ // PAL decrypts in place; copy into a writable scratch buffer.
+ EnsureDecryptScratch(frameSize);
+ ciphertext.Slice(0, frameSize).CopyTo(_decryptScratch);
+
+ SecurityStatusPal status = SslStreamPal.DecryptMessage(
+ _securityContext!,
+ _decryptScratch.AsSpan(0, frameSize),
+ plaintext,
+ out int decBytesWritten,
+ out int decLeftoverOffset,
+ out int decLeftoverLength);
+
+ switch (status.ErrorCode)
+ {
+ case SecurityStatusPalErrorCode.OK:
+ bytesConsumed = frameSize;
+ // Linux/macOS PALs write the plaintext directly into the destination span and
+ // (if it didn't fit, or the PAL prefers in-place) leave overflow in the encrypted
+ // span at leftoverOffset/leftoverLength. SChannel always decrypts in place and
+ // reports bytesWritten = 0 with leftoverOffset/leftoverLength pointing at the
+ // plaintext inside the encrypted span. Unify by appending the leftover slice
+ // after whatever was written into destination.
+ int needed = decBytesWritten + decLeftoverLength;
+ if (needed > plaintext.Length)
+ {
+ throw new InvalidOperationException(
+ $"Plaintext buffer too small: needed {needed}, got {plaintext.Length}.");
+ }
+ if (decLeftoverLength > 0)
+ {
+ _decryptScratch.AsSpan(decLeftoverOffset, decLeftoverLength)
+ .CopyTo(plaintext.Slice(decBytesWritten));
+ }
+ bytesWritten = needed;
+ return TlsOperationStatus.Complete;
+
+ case SecurityStatusPalErrorCode.ContextExpired:
+ case SecurityStatusPalErrorCode.ContextExpiredError:
+ bytesConsumed = frameSize;
+ return TlsOperationStatus.Closed;
+
+ case SecurityStatusPalErrorCode.Renegotiate:
+ // SChannel surfaces SEC_I_RENEGOTIATE for two distinct cases:
+ // - TLS 1.2 peer-initiated renegotiation (HelloRequest).
+ // - TLS 1.3 post-handshake messages (NewSessionTicket,
+ // KeyUpdate, post-handshake CertificateRequest).
+ // In either case the decrypted payload is the inner handshake
+ // record that must be fed back into ASC/ISC so SChannel can
+ // update its internal state. If we don't, the next DecryptMessage
+ // returns SEC_E_CONTEXT_EXPIRED because the context is stuck.
+ bytesConsumed = frameSize;
+ if (decLeftoverLength > 0)
+ {
+ ProcessPostHandshakeMessage(_decryptScratch.AsSpan(decLeftoverOffset, decLeftoverLength));
+ }
+ // Return Complete (not WantRead): we consumed input bytes but
+ // produced no plaintext. The caller's loop should re-enter to
+ // process any remaining buffered ciphertext (e.g. application
+ // data that arrived in the same TCP segment as the NST).
+ return TlsOperationStatus.Complete;
+
+ default:
+ throw new IOException(SR.net_io_decrypt, SslStreamPal.GetException(status));
+ }
+ }
+
+ // Empty-input probe used when the caller's buffer doesn't yet hold a complete TLS
+ // frame. On OpenSSL the PAL's record layer may still have plaintext queued from a
+ // prior call (handshake input that included trailing app-data, or a second record
+ // coalesced into the same TCP segment); calling DecryptMessage with an empty span
+ // surfaces it. On SChannel / SecureTransport the equivalent buffer does not exist,
+ // so the probe is skipped and the caller is asked for more bytes instead. The
+ // bytesConsumed out-parameter on the public Decrypt method is necessarily 0 here:
+ // no caller bytes were taken.
+ private TlsOperationStatus TryDrainBufferedPlaintext(Span plaintext, out int bytesWritten)
+ {
+ bytesWritten = 0;
+
+ if (!OperatingSystem.IsLinux() && !OperatingSystem.IsFreeBSD() && !OperatingSystem.IsAndroid())
+ {
+ return TlsOperationStatus.NeedMoreData;
+ }
+
+ SecurityStatusPal status = SslStreamPal.DecryptMessage(
+ _securityContext!,
+ Span.Empty,
+ plaintext,
+ out int decBytesWritten,
+ out int decLeftoverOffset,
+ out int decLeftoverLength);
+
+ if (status.ErrorCode != SecurityStatusPalErrorCode.OK)
+ {
+ // Anything other than success here means there's nothing to drain — the PAL
+ // is genuinely waiting on wire bytes. Surface as WantRead; fatal errors will
+ // resurface on the next regular Decrypt call with real ciphertext.
+ return TlsOperationStatus.NeedMoreData;
+ }
+
+ int produced = decBytesWritten + decLeftoverLength;
+ if (produced == 0)
+ {
+ return TlsOperationStatus.NeedMoreData;
+ }
+
+ if (produced > plaintext.Length)
+ {
+ throw new InvalidOperationException(
+ $"Plaintext buffer too small: needed {produced}, got {plaintext.Length}.");
+ }
+
+ if (decLeftoverLength > 0)
+ {
+ // PAL stashed overflow in the (empty) input span — impossible here, but mirror
+ // the main Decrypt path for symmetry. With Span.Empty as input, the OpenSSL
+ // PAL has nowhere to stash leftover and won't take this path.
+ _decryptScratch.AsSpan(decLeftoverOffset, decLeftoverLength)
+ .CopyTo(plaintext.Slice(decBytesWritten));
+ }
+
+ bytesWritten = produced;
+ return TlsOperationStatus.Complete;
+ }
+
+ // ── Post-handshake auth ──────────────────────────────────────────
+
+ ///
+ /// Server-side: requests a client certificate from the peer after the
+ /// initial handshake has completed. On TLS 1.3 this issues a
+ /// post-handshake authentication CertificateRequest; on TLS 1.2 it
+ /// initiates a renegotiation.
+ ///
+ ///
+ ///
+ /// The generated handshake bytes are staged into the pending-output
+ /// buffer (drained into ). The caller
+ /// must then continue normal /
+ /// operations; OpenSSL processes the peer's response transparently
+ /// inside subsequent SSL_read calls. Once the peer's
+ /// certificate has been received, it becomes observable via
+ /// .
+ ///
+ ///
+ private protected TlsOperationStatus RequestClientCertificateBufferedCore(Span ciphertext, out int bytesWritten)
+ {
+ ThrowIfDisposed();
+ bytesWritten = 0;
+
+#if TARGET_APPLE
+ // SecureTransport does not expose a post-handshake client-authentication
+ // path, and Network.framework does not provide renegotiation primitives.
+ throw new PlatformNotSupportedException(SR.net_ssl_renegotiate_not_supported);
+#else
+ if (!_context!.IsServer)
+ {
+ throw new InvalidOperationException("RequestClientCertificate can only be invoked on a server session.");
+ }
+
+ if (!_isHandshakeComplete || _securityContext == null || _securityContext.IsInvalid)
+ {
+ throw new InvalidOperationException("Handshake has not yet completed.");
+ }
+
+ if (_pendingLength == 0)
+ {
+ ProtocolToken token = SslStreamPal.Renegotiate(
+ ref ActiveCredentialsRef(),
+ ref _securityContext!,
+ _options);
+ try
+ {
+ if (token.Failed)
+ {
+ throw new AuthenticationException(SR.net_auth_SSPI, token.GetException());
+ }
+
+ if (token.Size > 0)
+ {
+ Debug.Assert(token.Payload != null);
+ AppendPending(new ReadOnlySpan(token.Payload, 0, token.Size));
+ }
+ }
+ finally
+ {
+ token.ReleasePayload();
+ }
+ }
+
+ bytesWritten = DrainTo(ciphertext);
+ return _pendingLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Complete;
+#endif
+ }
+
+ // ── Shutdown ──────────────────────────────────────────────────────
+
+ private bool _shutdownSent;
+
+ ///
+ /// Initiates a TLS close_notify shutdown and stages the resulting alert
+ /// record into the pending-output buffer (drained into ).
+ /// Subsequent calls drain any remaining shutdown output.
+ ///
+ ///
+ /// Returns if the caller must
+ /// drain more output before the shutdown record is fully written;
+ /// otherwise once all bytes have
+ /// been handed to the caller.
+ ///
+ private protected TlsOperationStatus ShutdownBufferedCore(Span ciphertext, out int bytesWritten)
+ {
+ ThrowIfDisposed();
+ bytesWritten = 0;
+
+ if (_securityContext == null || _securityContext.IsInvalid)
+ {
+ return TlsOperationStatus.Closed;
+ }
+
+ if (!_shutdownSent)
+ {
+ _shutdownSent = true;
+
+ SecurityStatusPal status = SslStreamPal.ApplyShutdownToken(_securityContext);
+ if (status.ErrorCode != SecurityStatusPalErrorCode.OK)
+ {
+ throw new IOException(SR.net_io_encrypt, SslStreamPal.GetException(status));
+ }
+
+ // Drive one step to extract the close_notify bytes the PAL queued
+ // into the underlying BIO. Input is empty; we only care about
+ // any output the PAL produces.
+ ProtocolToken token = default;
+ token.RentBuffer = true;
+ try
+ {
+ if (_context!.IsServer)
+ {
+ token = SslStreamPal.AcceptSecurityContext(
+ ref ActiveCredentialsRef(),
+ ref _securityContext,
+ ReadOnlySpan.Empty,
+ out _,
+ _options);
+ }
+ else
+ {
+ string hostName = TargetHostNameHelper.NormalizeHostName(_options.TargetHost);
+ token = SslStreamPal.InitializeSecurityContext(
+ ref ActiveCredentialsRef(),
+ ref _securityContext,
+ hostName,
+ ReadOnlySpan.Empty,
+ out _,
+ _options);
+ }
+
+ if (token.Size > 0)
+ {
+ Debug.Assert(token.Payload != null);
+ AppendPending(new ReadOnlySpan(token.Payload, 0, token.Size));
+ }
+ }
+ finally
+ {
+ token.ReleasePayload();
+ }
+ }
+
+ bytesWritten = DrainTo(ciphertext);
+ return _pendingLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Closed;
+ }
+
+ // ── Pending output ────────────────────────────────────────────────
+
+ private protected TlsOperationStatus DrainPendingOutputCore(Span ciphertext, out int bytesWritten)
+ {
+ ThrowIfDisposed();
+ bytesWritten = DrainTo(ciphertext);
+ return _pendingLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Complete;
+ }
+
+ // ── Internals ─────────────────────────────────────────────────────
+
+ private void AppendPending(ReadOnlySpan data)
+ {
+ if (data.IsEmpty)
+ {
+ return;
+ }
+
+ // Compact if anything was already drained.
+ if (_pending != null && _pendingOffset > 0)
+ {
+ if (_pendingLength > 0)
+ {
+ Buffer.BlockCopy(_pending, _pendingOffset, _pending, 0, _pendingLength);
+ }
+ _pendingOffset = 0;
+ }
+
+ int needed = _pendingLength + data.Length;
+ if (_pending == null || _pending.Length < needed)
+ {
+ byte[] bigger = ArrayPool.Shared.Rent(Math.Max(needed, 4096));
+ if (_pending is byte[] old)
+ {
+ if (_pendingLength > 0)
+ {
+ Buffer.BlockCopy(old, 0, bigger, 0, _pendingLength);
+ }
+ ArrayPool.Shared.Return(old);
+ }
+ _pending = bigger;
+ }
+
+ data.CopyTo(_pending.AsSpan(_pendingLength));
+ _pendingLength += data.Length;
+ }
+
+ private int DrainTo(Span output)
+ {
+ if (_pendingLength == 0)
+ {
+ return 0;
+ }
+
+ int n = Math.Min(output.Length, _pendingLength);
+ _pending!.AsSpan(_pendingOffset, n).CopyTo(output);
+ _pendingOffset += n;
+ _pendingLength -= n;
+
+ if (_pendingLength == 0)
+ {
+ ArrayPool.Shared.Return(_pending!);
+ _pending = null;
+ _pendingOffset = 0;
+ }
+
+ return n;
+ }
+
+ private void EnsureDecryptScratch(int size)
+ {
+ if (_decryptScratch == null || _decryptScratch.Length < size)
+ {
+ if (_decryptScratch != null)
+ {
+ ArrayPool.Shared.Return(_decryptScratch);
+ }
+ _decryptScratch = ArrayPool.Shared.Rent(size);
+ }
+ }
+
+ private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
+
+ private void ThrowIfContextNotSet()
+ {
+ if (_context is null)
+ {
+ throw new InvalidOperationException(SR.net_ssl_tlssession_context_not_set);
+ }
+ }
+
+ // Server-side: parses the ClientHello and returns a populated
+ // SslClientHelloInfo (SNI + supported versions), or null if more bytes
+ // are needed or the record is not a ClientHello. Used by the
+ // deferred-options path; does not mutate session state.
+ private static SslClientHelloInfo? TryParseClientHello(ReadOnlySpan input, out int frameLength)
+ {
+ frameLength = 0;
+ TlsFrameHelper.TlsFrameInfo frameInfo = default;
+ if (!TlsFrameHelper.TryGetFrameInfo(input, ref frameInfo))
+ {
+ return null;
+ }
+
+ if (frameInfo.HandshakeType != TlsHandshakeType.ClientHello)
+ {
+ return null;
+ }
+
+ frameLength = frameInfo.Header.Length;
+ return new SslClientHelloInfo(frameInfo.TargetName ?? string.Empty, frameInfo.SupportedVersions);
+ }
+
+ // Server-side SNI + certificate selection. Parses the ClientHello to
+ // extract the server_name extension (SNI) and, if a
+ // ServerCertificateSelectionCallback was supplied and no static
+ // CertificateContext has been resolved yet, invokes the callback to
+ // pick the cert. Mirrors the path SslStream takes in
+ // ReceiveBlobAsync/AcquireServerCredentials.
+ private bool ResolveServerCertificateFromClientHello(ReadOnlySpan input)
+ {
+ TlsFrameHelper.TlsFrameInfo frameInfo = default;
+ if (!TlsFrameHelper.TryGetFrameInfo(input, ref frameInfo))
+ {
+ return false;
+ }
+
+ if (frameInfo.HandshakeType != TlsHandshakeType.ClientHello)
+ {
+ return true;
+ }
+
+ if (!string.IsNullOrEmpty(frameInfo.TargetName))
+ {
+ _options.TargetHost = frameInfo.TargetName;
+ }
+
+ ServerCertificateSelectionCallback? selector = _options.ServerCertSelectionDelegate;
+ if (selector is null || _options.CertificateContext is not null)
+ {
+ return true;
+ }
+
+ X509Certificate? selected = selector(this, _options.TargetHost);
+ if (selected is null)
+ {
+ throw new AuthenticationException(SR.net_ssl_io_no_server_cert);
+ }
+
+ X509Certificate2? withKey = SslStream.FindCertificateWithPrivateKey(this, isServer: true, selected);
+ if (withKey is null)
+ {
+ throw new AuthenticationException(SR.net_ssl_io_no_server_cert);
+ }
+
+ _options.SetCertificateContextFromCert(withKey);
+ return true;
+ }
+
+ // ── Internal surface for the SslStream wedge (Linux/FreeBSD only) ─
+
+ // Direct accessors used by SslStream to mirror state into its own fields after
+ // each handshake step. Both handles are owned by this TlsSession; SslStream
+ // observes them via the mirror but does not dispose them.
+ // Set by the SslStream wedge: SslStream owns the validation flow and will
+ // invoke the user callback itself with the SslStream as the sender. Skipping
+ // here avoids invoking the callback twice and avoids handing TlsSession to
+ // user code that expects SslStream.
+ internal bool SuppressInternalCertificateValidation
+ {
+ get => _suppressInternalCertificateValidation;
+ set => _suppressInternalCertificateValidation = value;
+ }
+
+ internal TlsSecurityContext? SecurityContext => _securityContext;
+ internal TlsContext Context => _context!;
+ internal SafeFreeCredentials? CredentialsHandle
+ {
+ get => ActiveCredentialsRef();
+ set
+ {
+ if (_sessionCredentialsHandle is not null)
+ {
+ _sessionCredentialsHandle = value;
+ }
+ else
+ {
+ _context!.CredentialsHandle = value;
+ }
+ }
+ }
+
+ // Returns a ref to the credentials handle this session should use for its next
+ // PAL call. When _sessionCredentialsHandle is set (via SetClientCertificateContext),
+ // it takes precedence; otherwise the shared TlsContext.CredentialsHandle is used.
+ // Class instance refs have unrestricted lifetime, no [UnscopedRef] needed.
+ private ref SafeFreeCredentials? ActiveCredentialsRef()
+ => ref (_sessionCredentialsHandle is not null
+ ? ref _sessionCredentialsHandle
+ : ref _context!.CredentialsHandle);
+
+ // SslStream's GenerateToken replacement. Drives one ASC/ISC step via PAL and
+ // updates internal handshake-complete state. Returns the raw PAL token so the
+ // caller can preserve existing ProtocolToken-based plumbing (alerts, error
+ // mapping, NetEventSource).
+ internal ProtocolToken HandshakeStepForSslStream(ReadOnlySpan input, out int bytesConsumed)
+ {
+ ThrowIfDisposed();
+
+ ProtocolToken token;
+ if (_context!.IsServer)
+ {
+ token = SslStreamPal.AcceptSecurityContext(
+ ref ActiveCredentialsRef(),
+ ref _securityContext,
+ input,
+ out bytesConsumed,
+ _options);
+ }
+ else
+ {
+ string hostName = TargetHostNameHelper.NormalizeHostName(_options.TargetHost);
+ token = SslStreamPal.InitializeSecurityContext(
+ ref ActiveCredentialsRef(),
+ ref _securityContext,
+ hostName,
+ input,
+ out bytesConsumed,
+ _options);
+ }
+
+ if (token.Status.ErrorCode == SecurityStatusPalErrorCode.OK)
+ {
+ OnHandshakeCompleted();
+ }
+
+ return token;
+ }
+
+ private void OnHandshakeCompleted()
+ {
+ _isHandshakeComplete = true;
+ SslStreamPal.QueryContextConnectionInfo(_securityContext!, ref _connectionInfo);
+ SslStreamPal.QueryContextStreamSizes(_securityContext!, out StreamSizes streamSizes);
+ _headerSize = streamSizes.Header;
+ _trailerSize = streamSizes.Trailer;
+ if (streamSizes.MaximumMessage > 0)
+ {
+ _maxDataSize = Math.Min(streamSizes.MaximumMessage, MaxRecordPlaintext);
+ }
+
+ // Invoke remote-certificate validation callback (mirrors SslStream).
+ // Client: always validate the server cert.
+ // Server: always suspend so the caller's RemoteCertificateValidationCallback runs
+ // (it must see optional client certs and the no-cert case alike — only the
+ // RemoteCertificateNotAvailable error is suppressed in VerifyRemoteCertificateCore
+ // when there is no user callback and RemoteCertRequired is false).
+ if (_suppressInternalCertificateValidation)
+ {
+ return;
+ }
+
+ // If the caller already resolved validation via a prior suspension
+ // (defensive — current OpenSSL/SChannel paths only suspend once via
+ // the post-handshake hook below), don't re-suspend here.
+ if (_externalValidationResolved)
+ {
+ return;
+ }
+
+ CaptureRemoteCertificateForExternalValidation();
+ }
+
+ // Capture the peer certificate and chain so the caller can perform validation
+ // out of band. Keeps the cert in _externalPendingCert (not _remoteCertificate)
+ // so VerifyRemoteCertificateCore's renegotiation shortcut doesn't dispose it
+ // when AcceptWithDefaultValidation runs.
+ private void CaptureRemoteCertificateForExternalValidation()
+ {
+ X509Chain? chain = null;
+ _externalPendingCert = CertificateValidationPal.GetRemoteCertificate(
+ _securityContext, ref chain, _options.CertificateChainPolicy);
+
+ // Snapshot the peer-sent intermediates into a flat collection and dispose the
+ // platform-built chain immediately. The chain instance never escapes the PAL
+ // boundary into TlsSession state or its public surface.
+ if (chain is not null)
+ {
+ if (chain.ChainElements.Count > 1)
+ {
+ X509Certificate2Collection intermediates = new X509Certificate2Collection();
+ for (int i = 1; i < chain.ChainElements.Count; i++)
+ {
+ intermediates.Add(new X509Certificate2(chain.ChainElements[i].Certificate));
+ }
+ _externalRemoteCertificates = intermediates;
+ }
+ chain.Dispose();
+ }
+
+ _externalValidationPending = true;
+ }
+
+ // Acquire the SafeFreeCredentials the PAL needs for the first ASC/ISC
+ // call. OpenSSL handles credential acquisition lazily inside the PAL,
+ // but SChannel rejects ASC/ISC with a null credentials handle.
+ //
+ // Server requires a pre-set CertificateContext (or one resolved via
+ // ServerCertSelectionDelegate above); the client connects anonymously.
+ // SslSessionsCache, the legacy CertSelectionDelegate, and client
+ // certificate selection are not yet integrated.
+ private void EnsureCredentialsAcquired()
+ {
+ if (_context!.CredentialsHandle is not null)
+ {
+ return;
+ }
+
+ _context!.CredentialsHandle = SslStreamPal.AcquireCredentialsHandle(_options, false);
+ }
+
+ // Feed a decrypted post-handshake message (e.g. TLS 1.3 NewSessionTicket
+ // or KeyUpdate) back through ASC/ISC so SChannel updates its internal
+ // state. The PAL may or may not produce a reply token; if it does, stage
+ // it for the caller to send on the next drain.
+ private void ProcessPostHandshakeMessage(ReadOnlySpan data)
+ {
+ if (data.IsEmpty)
+ {
+ return;
+ }
+
+ ProtocolToken token = default;
+ token.RentBuffer = true;
+ try
+ {
+ if (_context!.IsServer)
+ {
+ token = SslStreamPal.AcceptSecurityContext(
+ ref ActiveCredentialsRef(),
+ ref _securityContext,
+ data,
+ out _,
+ _options);
+ }
+ else
+ {
+ string hostName = TargetHostNameHelper.NormalizeHostName(_options.TargetHost);
+ token = SslStreamPal.InitializeSecurityContext(
+ ref ActiveCredentialsRef(),
+ ref _securityContext,
+ hostName,
+ data,
+ out _,
+ _options);
+ }
+
+ if (token.Size > 0)
+ {
+ Debug.Assert(token.Payload != null);
+ AppendPending(new ReadOnlySpan(token.Payload, 0, token.Size));
+ }
+ }
+ finally
+ {
+ token.ReleasePayload();
+ }
+ }
+
+ // ── Socket-bound I/O ─────────────────────────────────────────────
+ //
+ // These methods are only valid when the session was created via
+ // Create(TlsContext, SafeSocketHandle). They drive ciphertext on the
+ // bound non-blocking socket and translate WouldBlock into WantRead/
+ // WantWrite back to the caller so a select/epoll/IOCP-like loop can
+ // schedule the next attempt.
+
+ private const int SocketScratchSize = MaxRecordPlaintext + 256;
+
+ private void ThrowIfNotSocketBound()
+ {
+ if (_socketHandle is null)
+ {
+ throw new InvalidOperationException("Session is not socket-bound.");
+ }
+ }
+
+ // Drains any TLS bytes that we previously failed to fully send into the
+ // socket. Returns true if pending output is now empty, false if the
+ // socket would block (WantWrite should be surfaced).
+ private bool TryDrainPendingToSocket(out SocketError lastError)
+ {
+ lastError = SocketError.Success;
+ while (_pendingLength > 0)
+ {
+ int sent = _socket!.Send(
+ new ReadOnlySpan(_pending!, _pendingOffset, _pendingLength),
+ SocketFlags.None,
+ out SocketError err);
+ lastError = err;
+ if (sent > 0)
+ {
+ _pendingOffset += sent;
+ _pendingLength -= sent;
+ if (_pendingLength == 0)
+ {
+ _pendingOffset = 0;
+ return true;
+ }
+ continue;
+ }
+ return false;
+ }
+ return true;
+ }
+
+ private protected TlsOperationStatus HandshakeSocketCore()
+ {
+ ThrowIfDisposed();
+ ThrowIfNotSocketBound();
+
+ if (_isHandshakeComplete && !_externalValidationPending && !_externalValidationResolved)
+ {
+ return TlsOperationStatus.Complete;
+ }
+
+ TlsOperationStatus? fast = null;
+ TryFastHandshake(ref fast);
+ if (fast.HasValue)
+ {
+ return fast.Value;
+ }
+
+ TryPeekClientHello(ref fast);
+ if (fast.HasValue)
+ {
+ return fast.Value;
+ }
+
+ _socketInBuf ??= ArrayPool.Shared.Rent(SocketScratchSize);
+ byte[] scratch = ArrayPool.Shared.Rent(SocketScratchSize);
+ try
+ {
+ while (true)
+ {
+ if (_pendingLength > 0)
+ {
+ if (!TryDrainPendingToSocket(out SocketError drainErr))
+ {
+ if (drainErr == SocketError.WouldBlock)
+ {
+ return TlsOperationStatus.DestinationTooSmall;
+ }
+ throw new SocketException((int)drainErr);
+ }
+ }
+
+ TlsOperationStatus status = HandshakeBufferedCore(
+ new ReadOnlySpan(_socketInBuf, 0, _socketInUsed),
+ scratch,
+ out int consumed,
+ out int produced);
+
+ if (consumed > 0)
+ {
+ int remaining = _socketInUsed - consumed;
+ if (remaining > 0)
+ {
+ Buffer.BlockCopy(_socketInBuf, consumed, _socketInBuf, 0, remaining);
+ }
+ _socketInUsed = remaining;
+ }
+
+ if (produced > 0)
+ {
+ int offset = 0;
+ while (offset < produced)
+ {
+ int sent = _socket!.Send(
+ new ReadOnlySpan(scratch, offset, produced - offset),
+ SocketFlags.None,
+ out SocketError sendErr);
+ if (sent > 0)
+ {
+ offset += sent;
+ continue;
+ }
+ if (sendErr == SocketError.WouldBlock)
+ {
+ // Stash the unsent tail so the next call resumes the drain.
+ AppendPending(new ReadOnlySpan(scratch, offset, produced - offset));
+ return TlsOperationStatus.DestinationTooSmall;
+ }
+ throw new SocketException((int)sendErr);
+ }
+ }
+
+ switch (status)
+ {
+ case TlsOperationStatus.Complete:
+ return TlsOperationStatus.Complete;
+
+ case TlsOperationStatus.NeedMoreData:
+ if (_socketInUsed >= _socketInBuf.Length)
+ {
+ // Should not happen with conservative scratch sizing, but guard.
+ Array.Resize(ref _socketInBuf, _socketInBuf.Length * 2);
+ }
+ int received = _socket!.Receive(
+ _socketInBuf.AsSpan(_socketInUsed),
+ SocketFlags.None,
+ out SocketError recvErr);
+ if (received > 0)
+ {
+ _socketInUsed += received;
+ continue;
+ }
+ if (recvErr == SocketError.WouldBlock)
+ {
+ return TlsOperationStatus.NeedMoreData;
+ }
+ if (received == 0)
+ {
+ return TlsOperationStatus.Closed;
+ }
+ throw new SocketException((int)recvErr);
+
+ case TlsOperationStatus.DestinationTooSmall:
+ // Output is staged; loop drains it on next iteration.
+ continue;
+
+ default:
+ return status;
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(scratch);
+ }
+ }
+
+ private protected TlsOperationStatus ReadSocketCore(Span buffer, out int bytesRead)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotSocketBound();
+ bytesRead = 0;
+
+ if (!_isHandshakeComplete)
+ {
+ throw new InvalidOperationException("Handshake has not yet completed.");
+ }
+
+ TlsOperationStatus? fast = null;
+ TryFastRead(buffer, ref bytesRead, ref fast);
+ if (fast.HasValue)
+ {
+ return fast.Value;
+ }
+
+ _socketInBuf ??= ArrayPool.Shared.Rent(SocketScratchSize);
+
+ while (true)
+ {
+ if (_socketInUsed > 0)
+ {
+ TlsOperationStatus status = ReadBufferedCore(
+ new ReadOnlySpan(_socketInBuf, 0, _socketInUsed),
+ buffer,
+ out int consumed,
+ out int produced);
+
+ if (consumed > 0)
+ {
+ int remaining = _socketInUsed - consumed;
+ if (remaining > 0)
+ {
+ Buffer.BlockCopy(_socketInBuf, consumed, _socketInBuf, 0, remaining);
+ }
+ _socketInUsed = remaining;
+ }
+
+ bytesRead = produced;
+
+ if (status == TlsOperationStatus.Complete && produced > 0)
+ {
+ return TlsOperationStatus.Complete;
+ }
+ if (status == TlsOperationStatus.Closed)
+ {
+ return TlsOperationStatus.Closed;
+ }
+ if (status == TlsOperationStatus.Complete && produced == 0)
+ {
+ // Post-handshake message consumed; loop to try more.
+ continue;
+ }
+ if (status != TlsOperationStatus.NeedMoreData)
+ {
+ return status;
+ }
+ // WantRead: fall through to socket recv.
+ }
+
+ if (_socketInUsed >= _socketInBuf.Length)
+ {
+ Array.Resize(ref _socketInBuf, _socketInBuf.Length * 2);
+ }
+ int received = _socket!.Receive(
+ _socketInBuf.AsSpan(_socketInUsed),
+ SocketFlags.None,
+ out SocketError recvErr);
+ if (received > 0)
+ {
+ _socketInUsed += received;
+ continue;
+ }
+ if (recvErr == SocketError.WouldBlock)
+ {
+ return TlsOperationStatus.NeedMoreData;
+ }
+ if (received == 0)
+ {
+ return TlsOperationStatus.Closed;
+ }
+ throw new SocketException((int)recvErr);
+ }
+ }
+
+ private protected TlsOperationStatus WriteSocketCore(ReadOnlySpan buffer, out int bytesWritten)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotSocketBound();
+ bytesWritten = 0;
+
+ if (!_isHandshakeComplete)
+ {
+ throw new InvalidOperationException("Handshake has not yet completed.");
+ }
+
+ TlsOperationStatus? fast = null;
+ TryFastWrite(buffer, ref bytesWritten, ref fast);
+ if (fast.HasValue)
+ {
+ return fast.Value;
+ }
+
+ // Drain any previously stashed ciphertext first.
+ if (_pendingLength > 0)
+ {
+ if (!TryDrainPendingToSocket(out SocketError drainErr))
+ {
+ if (drainErr == SocketError.WouldBlock)
+ {
+ return TlsOperationStatus.DestinationTooSmall;
+ }
+ throw new SocketException((int)drainErr);
+ }
+ }
+
+ if (buffer.IsEmpty)
+ {
+ return TlsOperationStatus.Complete;
+ }
+
+ byte[] scratch = ArrayPool.Shared.Rent(SocketScratchSize);
+ try
+ {
+ int totalConsumed = 0;
+ while (totalConsumed < buffer.Length)
+ {
+ TlsOperationStatus encStatus = WriteBufferedCore(
+ buffer.Slice(totalConsumed),
+ scratch,
+ out int consumed,
+ out int produced);
+
+ totalConsumed += consumed;
+
+ if (produced > 0)
+ {
+ int offset = 0;
+ while (offset < produced)
+ {
+ int sent = _socket!.Send(
+ new ReadOnlySpan(scratch, offset, produced - offset),
+ SocketFlags.None,
+ out SocketError sendErr);
+ if (sent > 0)
+ {
+ offset += sent;
+ continue;
+ }
+ if (sendErr == SocketError.WouldBlock)
+ {
+ AppendPending(new ReadOnlySpan(scratch, offset, produced - offset));
+ bytesWritten = totalConsumed;
+ return TlsOperationStatus.DestinationTooSmall;
+ }
+ throw new SocketException((int)sendErr);
+ }
+ }
+
+ if (encStatus == TlsOperationStatus.DestinationTooSmall)
+ {
+ // Pending output owed; resume next call.
+ bytesWritten = totalConsumed;
+ return TlsOperationStatus.DestinationTooSmall;
+ }
+ if (encStatus != TlsOperationStatus.Complete)
+ {
+ bytesWritten = totalConsumed;
+ return encStatus;
+ }
+ if (consumed == 0)
+ {
+ // Nothing more to do (shouldn't happen with non-empty buffer).
+ break;
+ }
+ }
+
+ bytesWritten = totalConsumed;
+ return TlsOperationStatus.Complete;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(scratch);
+ }
+ }
+
+ // Simple driver that runs a buffered "output-only" op (Shutdown /
+ // RequestClientCertificate) and drains its staged ciphertext to the socket.
+ private TlsOperationStatus DriveBufferedOpOverSocket(Func, (TlsOperationStatus status, int written)> op)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotSocketBound();
+
+ // Drain any leftover pending output before staging new bytes.
+ if (_pendingLength > 0)
+ {
+ if (!TryDrainPendingToSocket(out SocketError leftoverErr))
+ {
+ if (leftoverErr == SocketError.WouldBlock)
+ {
+ return TlsOperationStatus.DestinationTooSmall;
+ }
+ throw new SocketException((int)leftoverErr);
+ }
+ }
+
+ byte[] scratch = ArrayPool.Shared.Rent(SocketScratchSize);
+ try
+ {
+ (TlsOperationStatus status, int written) = op(scratch);
+ if (written > 0)
+ {
+ int offset = 0;
+ while (offset < written)
+ {
+ int sent = _socket!.Send(
+ new ReadOnlySpan(scratch, offset, written - offset),
+ SocketFlags.None,
+ out SocketError sendErr);
+ if (sent > 0)
+ {
+ offset += sent;
+ continue;
+ }
+ if (sendErr == SocketError.WouldBlock)
+ {
+ AppendPending(new ReadOnlySpan(scratch, offset, written - offset));
+ return TlsOperationStatus.DestinationTooSmall;
+ }
+ throw new SocketException((int)sendErr);
+ }
+ }
+ return status;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(scratch);
+ }
+ }
+
+ private protected TlsOperationStatus ShutdownSocketCore()
+ => DriveBufferedOpOverSocket(dest =>
+ {
+ TlsOperationStatus s = ShutdownBufferedCore(dest, out int w);
+ return (s, w);
+ });
+
+ private protected TlsOperationStatus RequestClientCertificateSocketCore()
+ => DriveBufferedOpOverSocket(dest =>
+ {
+ TlsOperationStatus s = RequestClientCertificateBufferedCore(dest, out int w);
+ return (s, w);
+ });
+
+ // Platform hooks. Implemented by the OpenSSL partial (TlsSession.OpenSsl.cs)
+ // to bind the socket fd directly to the SSL object and drive ciphertext
+ // through OpenSSL. On Windows (SChannel) these are no-ops and the buffered
+ // ProcessHandshake/Encrypt/Decrypt path above is used unchanged.
+ partial void EnableNativeSocketBinding(SafeSocketHandle socket, ref bool nativeBindingEnabled);
+ partial void TryFastHandshake(ref TlsOperationStatus? result);
+ partial void TryPeekClientHello(ref TlsOperationStatus? result);
+ partial void TryFastRead(Span buffer, ref int bytesRead, ref TlsOperationStatus? result);
+ partial void TryFastWrite(ReadOnlySpan buffer, ref int bytesWritten, ref TlsOperationStatus? result);
+
+ // Fires at the end of SetContext. Platforms with a deferred-server
+ // fast path (OpenSSL socket-bound sessions) use this hook to activate
+ // native binding now that server options are known.
+ partial void OnServerContextSet();
+
+ // Fires from Dispose so the OpenSSL partial can release the peek BIO if the
+ // session is disposed before its ownership is transferred to an SSL* handle.
+ partial void OnDispose();
+
+ // Fires from GetClientHelloBytes so the OpenSSL partial can return a span
+ // over the socket-replay BIO's retained peek buffer. No-op on the buffered
+ // path; the getter falls back to the managed byte[] copy.
+ partial void TryGetNativeClientHelloBytes(ref ReadOnlySpan bytes);
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+ _disposed = true;
+
+ DisposeExternalRemoteCertificates();
+ _externalPendingCert?.Dispose();
+ _externalPendingCert = null;
+
+ _securityContext?.Dispose();
+ _securityContext = null;
+
+ // Disposes the underlying SafeSocketHandle as well (ownership transferred at Create).
+ if (_socket is not null)
+ {
+ _socket.Dispose();
+ _socket = null;
+ }
+ else
+ {
+ _socketHandle?.Dispose();
+ }
+ _socketHandle = null;
+
+ if (_ownsOptions)
+ {
+ _options.Dispose();
+ }
+
+ if (_pending != null)
+ {
+ ArrayPool.Shared.Return(_pending);
+ _pending = null;
+ }
+ if (_decryptScratch != null)
+ {
+ ArrayPool.Shared.Return(_decryptScratch);
+ _decryptScratch = null;
+ }
+ if (_socketInBuf != null)
+ {
+ ArrayPool.Shared.Return(_socketInBuf);
+ _socketInBuf = null;
+ }
+
+ OnDispose();
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSocketSession.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSocketSession.cs
new file mode 100644
index 00000000000000..b673aa93f5e736
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSocketSession.cs
@@ -0,0 +1,58 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Net.Sockets;
+
+namespace System.Net.Security
+{
+ ///
+ /// Non-blocking TLS session bound to a caller-supplied non-blocking
+ /// . The session performs its own ciphertext
+ /// I/O on the socket via , ,
+ /// , , and
+ /// . The socket must be configured
+ /// non-blocking; behavior on a blocking socket is unspecified.
+ ///
+ ///
+ /// The session takes ownership of the supplied socket and disposes it with
+ /// the session. Call with a client or
+ /// server before invoking any operation.
+ ///
+ [Experimental(Experimentals.LowLevelTlsDiagId, UrlFormat = Experimentals.SharedUrlFormat)]
+ public sealed class TlsSocketSession : TlsSession
+ {
+ private readonly SafeSocketHandle _socket;
+
+ public TlsSocketSession(SafeSocketHandle socket)
+ {
+ ArgumentNullException.ThrowIfNull(socket);
+ _socket = socket;
+ }
+
+ internal override void OnContextInitialized()
+ {
+ AttachSocket(_socket);
+ }
+
+ /// The socket the session is bound to. Owned by the session.
+ public SafeSocketHandle Socket => _socket;
+
+ /// Drives the TLS handshake to completion, sending and receiving via the socket.
+ public TlsOperationStatus Handshake() => HandshakeSocketCore();
+
+ /// Reads decrypted application bytes from the socket into .
+ public TlsOperationStatus Read(Span buffer, out int bytesRead)
+ => ReadSocketCore(buffer, out bytesRead);
+
+ /// Encrypts and sends as one or more TLS records over the socket.
+ public TlsOperationStatus Write(ReadOnlySpan buffer, out int bytesWritten)
+ => WriteSocketCore(buffer, out bytesWritten);
+
+ /// Sends a TLS close_notify alert on the socket.
+ public TlsOperationStatus Shutdown() => ShutdownSocketCore();
+
+ /// Server-side only. Sends a CertificateRequest on the socket for TLS 1.3 post-handshake authentication.
+ public TlsOperationStatus RequestClientCertificate() => RequestClientCertificateSocketCore();
+ }
+}
diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamStreamToStreamTest.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamStreamToStreamTest.cs
index 494080f0c794a0..c7b44dc881c122 100644
--- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamStreamToStreamTest.cs
+++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamStreamToStreamTest.cs
@@ -76,6 +76,11 @@ public static IEnumerable