diff --git a/TlsSession-proposal.md b/TlsSession-proposal.md
new file mode 100644
index 00000000000000..6b93b8a8e18094
--- /dev/null
+++ b/TlsSession-proposal.md
@@ -0,0 +1,757 @@
+# 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
+
+```csharp
+namespace System.Net.Security;
+
+public enum TlsOperationStatus
+{
+ /// The call made forward progress. Check consumed/produced.
+ Complete = 0,
+
+ /// The session needs more ciphertext from the peer to make progress.
+ WantRead = 1,
+
+ ///
+ /// The session has ciphertext to send. Call
+ /// before retrying.
+ ///
+ WantWrite = 2,
+
+ /// The transport is gone or close_notify was received. Dispose the session.
+ Closed = 3,
+
+ ///
+ /// Server-side only. The peer's ClientHello has been received but server options
+ /// have not been supplied. Inspect ,
+ /// call , then retry.
+ ///
+ NeedsServerOptions = 4,
+
+ ///
+ /// The peer presented a certificate that the session is not validating.
+ /// Inspect /
+ /// , call
+ /// , then retry.
+ ///
+ NeedsCertificateValidation = 5,
+}
+
+///
+/// Long-lived TLS configuration. Thread-safe; create once per logical endpoint
+/// and share across many instances.
+///
+public sealed class TlsContext : IDisposable
+{
+ 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. In socket-bound mode
+/// the session reads and writes the bound socket directly; 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.
+ ///
+ public static TlsSession Create(TlsContext context, SafeSocketHandle socket);
+
+ // ── Identity / state ──────────────────────────────────────────────────
+
+ public bool IsServer { get; }
+ public bool IsSocketBound { get; }
+ public SafeSocketHandle? Socket { get; }
+ public bool IsHandshakeComplete { get; }
+
+ /// SNI / target hostname. Set before the first handshake call.
+ public string? TargetHostName { get; set; }
+
+ // ── Negotiated info (valid after IsHandshakeComplete) ─────────────────
+
+ public SslProtocols NegotiatedProtocol { get; }
+ public TlsCipherSuite NegotiatedCipherSuite { get; }
+ public SslApplicationProtocol NegotiatedApplicationProtocol { get; }
+ public bool SessionResumed { get; }
+
+ public X509Certificate2? GetLocalCertificate();
+
+ ///
+ /// 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 certificates the peer sent in its Certificate message (excluding the leaf).
+ /// Pass to 's ExtraStore when building.
+ ///
+ public X509Certificate2Collection GetRemoteCertificateChain();
+
+ /// RFC 5929 channel binding token.
+ public ChannelBinding? GetChannelBinding(ChannelBindingKind kind);
+
+ // ── Suspension data ───────────────────────────────────────────────────
+
+ ///
+ /// Valid only while the last status was .
+ ///
+ public SslClientHelloInfo? PendingClientHello { get; }
+
+ ///
+ /// Supplies server options resolved from .
+ /// Throws if the session is not currently suspended on NeedsServerOptions.
+ ///
+ public void SetServerOptions(SslServerAuthenticationOptions options);
+
+ ///
+ /// Supplies the verdict for the peer certificate.
+ /// On , the session emits its Finished message on the next call.
+ /// On , the session emits a fatal bad_certificate alert.
+ /// Throws if the session is not currently suspended on NeedsCertificateValidation.
+ ///
+ public void CompleteCertificateValidation(bool accept);
+
+ // ── State machine (always available, both modes) ──────────────────────
+
+ /// Advances the handshake by one step.
+ public TlsOperationStatus ProcessHandshake(
+ ReadOnlySpan input, Span output,
+ out int consumed, out int produced);
+
+ ///
+ /// Decrypts one record's worth of ciphertext. Throws
+ /// before the handshake is complete.
+ ///
+ public TlsOperationStatus Decrypt(
+ ReadOnlySpan ciphertext, Span plaintext,
+ out int consumed, out int produced);
+
+ ///
+ /// Encrypts plaintext into ciphertext. Throws
+ /// before the handshake is complete.
+ ///
+ public TlsOperationStatus Encrypt(
+ ReadOnlySpan plaintext, Span ciphertext,
+ out int consumed, out int produced);
+
+ ///
+ /// 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 WantWrite; call again.
+ ///
+ public TlsOperationStatus DrainPendingOutput(Span ciphertext, out int produced);
+
+ // ── Socket-bound convenience ──────────────────────────────────────────
+ // Throw InvalidOperationException if !IsSocketBound.
+
+ public TlsOperationStatus Handshake();
+ public TlsOperationStatus Read(Span buffer, out int bytesRead);
+ public TlsOperationStatus Write(ReadOnlySpan buffer, out int bytesWritten);
+
+ // ── Shutdown / advanced ───────────────────────────────────────────────
+
+ /// Initiates a TLS shutdown (sends close_notify).
+ public TlsOperationStatus Shutdown();
+
+ ///
+ /// Initiates TLS 1.3 post-handshake authentication (server only).
+ /// Subsequent / calls drive the
+ /// auth flow and may surface .
+ ///
+ public TlsOperationStatus RequestPostHandshakeAuthentication();
+
+ public void Dispose();
+}
+
+/// Convenience helpers built on top of .
+public static class TlsSessionExtensions
+{
+ ///
+ /// Validates the peer's certificate using the same default policy as
+ /// , then accepts or rejects the suspended handshake.
+ ///
+ public static bool AcceptWithDefaultValidation(
+ this TlsSession session,
+ X509RevocationMode revocationMode = X509RevocationMode.NoCheck);
+
+ ///
+ /// Performs the default validation check without supplying a verdict to the
+ /// session. The caller is responsible for calling
+ /// .
+ ///
+ public static bool PassesDefaultValidation(
+ this TlsSession session,
+ X509RevocationMode revocationMode = X509RevocationMode.NoCheck);
+}
+```
+
+---
+
+## Contract
+
+### Status semantics
+
+| Status | Meaning | Caller action |
+|---|---|---|
+| `Complete` | Call made progress | Check `produced` / `consumed`; 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 |
+| `NeedsServerOptions` | ClientHello received, options not supplied | Inspect `PendingClientHello`; call `SetServerOptions`; retry |
+| `NeedsCertificateValidation` | Peer cert presented, awaiting verdict | Inspect cert; call `CompleteCertificateValidation`; 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). If the verdict is reject, 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` |
+| `SetServerOptions` / `CompleteCertificateValidation` while not suspended on the matching state | `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.
+
+---
+
+## 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.
+ bool ok = await Task.Run(() => session.PassesDefaultValidation(), ct);
+ session.CompleteCertificateValidation(ok);
+ if (!ok) await DrainAsync(session, socket, netOut, ct); // flush alert
+ 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();
+TlsContext fallback = TlsContext.Create(MinimalServerOptions()); // for ClientHello parsing only
+
+async Task HandleAsync(Socket client, CancellationToken ct)
+{
+ client.Blocking = false;
+ using var session = TlsSession.Create(fallback, 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.PendingClientHello!;
+ var ctx = contextsBySni.GetValueOrDefault(hello.ServerName)
+ ?? contextsBySni["default"];
+ session.SetServerOptions(BuildOptionsFor(ctx, hello));
+ 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())
+ {
+ // Optional: log the rejection.
+ }
+ continue;
+```
+
+`AcceptWithDefaultValidation` performs the same chain build, hostname match,
+and revocation policy that `SslStream` uses by default. 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`).
+- Renegotiation, TLS 1.3 KeyUpdate, NewSessionTicket, alerts (handled implicitly).
+- Channel binding (`GetChannelBinding`).
+- All `SslServer/ClientAuthenticationOptions` configuration knobs.
+
+### Covered with explicit suspension protocol
+
+- `ServerOptionsSelectionCallback` → `NeedsServerOptions` + `SetServerOptions`.
+- `RemoteCertificateValidationCallback` → `NeedsCertificateValidation` +
+ `CompleteCertificateValidation`.
+- Post-handshake authentication (TLS 1.3 PHA) → `RequestPostHandshakeAuthentication`.
+
+### Layered above the primitive
+
+- `Stream` shape → adapter.
+- `IDuplexPipe` shape → `TlsDuplexPipe`.
+- Default validation policy → `AcceptWithDefaultValidation` extension.
+
+### 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`, `Stream` adapter,
+ `TlsSessionExtensions`. 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/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 9c95ab07365ad4..cf325b514f3d94 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
@@ -688,6 +688,15 @@ internal static SecurityStatusPalErrorCode DoSslHandshake(SafeSslHandle context,
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.NeedsRemoteCertificateValidation;
+ }
+
if (errorCode == Ssl.SslErrorCode.SSL_ERROR_SSL && context.CertificateValidationException is Exception ex)
{
// Clear the OpenSSL error queue since we are using our own
@@ -874,7 +883,25 @@ 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");
+
+ // External-validation fast path: when there is no in-callback
+ // validator (e.g. TlsSession owns validation) and the options
+ // asked us to defer, try SSL_set_retry_verify so the handshake
+ // pauses here on OpenSSL 3.0+. If the runtime is older (1.1.x),
+ // accept the cert and let the caller validate after the
+ // handshake completes.
+ if (options.RemoteCertificateValidator is null && options.DeferCertificateValidation)
+ {
+ if (Ssl.SslSetRetryVerify(sslHandle!) == 1)
+ {
+ return -1;
+ }
+
+ 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
@@ -888,7 +915,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 fd5f3608bbd9df..0e8bf1d2723610 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
@@ -190,6 +190,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);
@@ -381,6 +384,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.
@@ -451,6 +455,10 @@ public static SafeSslHandle Create(SafeSslContextHandle context, SslAuthenticati
handle._authOptionsHandle = new WeakGCHandle(options);
Interop.Ssl.SslSetData(handle, WeakGCHandle.ToIntPtr(handle._authOptionsHandle));
+ // CertVerifyCallback needs the SafeSslHandle to stash a
+ // CertificateValidationException; expose it via the options.
+ options.SafeSslHandle = handle;
+
// SslSetBio will transfer ownership of the BIO handles to the SSL context
try
{
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..173bb2888a4af0 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,52 @@ 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,
}
+ public enum TlsOperationStatus
+ {
+ Complete = 0,
+ WantRead = 1,
+ WantWrite = 2,
+ Closed = 3,
+ WantCredentials = 4,
+ NeedsCertificateValidation = 5,
+ NeedsServerOptions = 6,
+ }
+ public sealed partial class TlsContext : System.IDisposable
+ {
+ internal TlsContext() { }
+ public bool IsServer { get { throw null; } }
+ public static System.Net.Security.TlsContext Create(System.Net.Security.SslServerAuthenticationOptions? options) { throw null; }
+ public static System.Net.Security.TlsContext Create(System.Net.Security.SslClientAuthenticationOptions options) { throw null; }
+ public void Dispose() { }
+ }
+ public sealed partial class TlsSession : System.IDisposable
+ {
+ internal TlsSession() { }
+ public bool IsServer { get { throw null; } }
+ 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 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 static System.Net.Security.TlsSession Create(System.Net.Security.TlsContext context) { throw null; }
+ public System.Net.Security.TlsOperationStatus ProcessHandshake(System.ReadOnlySpan input, System.Span output, out int consumed, out int produced) { throw null; }
+ public System.Net.Security.TlsOperationStatus Encrypt(System.ReadOnlySpan plaintext, System.Span ciphertext, out int consumed, out int produced) { throw null; }
+ public System.Net.Security.TlsOperationStatus Decrypt(System.ReadOnlySpan ciphertext, System.Span plaintext, out int consumed, out int produced) { throw null; }
+ public System.Net.Security.TlsOperationStatus Shutdown(System.Span ciphertext, out int produced) { throw null; }
+ public System.Net.Security.TlsOperationStatus DrainPendingOutput(System.Span ciphertext, out int produced) { 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 SetServerOptions(System.Net.Security.SslServerAuthenticationOptions options) { }
+ 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 System.Net.Security.TlsOperationStatus RequestClientCertificate(System.Span ciphertext, out int produced) { throw null; }
+ public void Dispose() { }
+ }
}
namespace System.Security.Authentication
{
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 1b208fca00a699..eb7da9547473e6 100644
--- a/src/libraries/System.Net.Security/src/System.Net.Security.csproj
+++ b/src/libraries/System.Net.Security/src/System.Net.Security.csproj
@@ -62,6 +62,16 @@
+
+
+
+
+
+
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/SslAuthenticationOptions.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslAuthenticationOptions.cs
index 6aaa1932ea76e2..e44b8ae2b68005 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
@@ -225,6 +225,31 @@ 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; }
+
+ // When true, the OpenSSL CertVerifyCallback always defers certificate
+ // validation. On OpenSSL 3.0+ the callback uses SSL_set_retry_verify
+ // to suspend the handshake; on older versions it accepts the cert and
+ // validation runs after the handshake completes. Set by TlsSession.
+ internal bool DeferCertificateValidation { 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.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 28b274aa8f58db..3ef1cfc79014d1 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
@@ -786,6 +786,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())
{
@@ -798,6 +807,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
@@ -1061,17 +1072,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,
@@ -1081,13 +1123,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;
}
@@ -1095,16 +1137,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;
}
@@ -1127,36 +1169,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;
}
@@ -1166,16 +1208,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.CanGenerateCustomAlerts && !SslStreamPal.CertValidationInCallback)
+ if (SslStreamPal.CanGenerateCustomAlerts && !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)
@@ -1305,22 +1347,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)
@@ -1331,7 +1373,7 @@ private void LogCertificateValidation(RemoteCertificateValidationCallback? remot
{
chainStatusString += "\t" + chainStatus.StatusInformation;
}
- NetEventSource.Log.RemoteCertificateError(this, chainStatusString);
+ NetEventSource.Log.RemoteCertificateError(sender, chainStatusString);
}
}
@@ -1339,18 +1381,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..6bd52427625a16
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.TlsSessionWedge.cs
@@ -0,0 +1,153 @@
+// 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 TlsSession? _tlsSession;
+
+ private void EnsureTlsSession()
+ {
+ if (_tlsSession is null)
+ {
+ Debug.Assert(_sslAuthenticationOptions != null);
+ TlsContext ctx = TlsContext.WrapShared(_sslAuthenticationOptions);
+ _tlsSession = TlsSession.Create(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/TlsContext.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs
new file mode 100644
index 00000000000000..6acb898f93162a
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs
@@ -0,0 +1,120 @@
+// 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;
+
+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.
+ ///
+ public sealed class TlsContext : IDisposable
+ {
+ private readonly SslAuthenticationOptions _options;
+ private readonly bool _ownsOptions;
+ private bool _hasServerOptions;
+
+ // Credential handle is 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; the field acts as shared
+ // storage that SslStream and TlsSession both read/write via ref.
+ internal SafeFreeCredentials? CredentialsHandle;
+
+ private TlsContext(SslAuthenticationOptions options, bool ownsOptions, bool hasServerOptions)
+ {
+ _options = options;
+ _ownsOptions = ownsOptions;
+ _hasServerOptions = hasServerOptions;
+ }
+
+ internal SslAuthenticationOptions Options => _options;
+
+ // Server-only. False if the context was created with null options and
+ // SetServerOptions has not yet been called on a session.
+ internal bool HasServerOptions => _hasServerOptions;
+
+ // Applies user-supplied server options to the deferred bag. Called from
+ // TlsSession.SetServerOptions once the caller has inspected the ClientHello.
+ internal void ApplyServerOptions(SslServerAuthenticationOptions options)
+ {
+ Debug.Assert(_options.IsServer);
+ Debug.Assert(!_hasServerOptions);
+ _options.UpdateOptions(options);
+ _hasServerOptions = true;
+ }
+
+ public bool IsServer => _options.IsServer;
+
+ ///
+ /// Creates a server-side TLS context.
+ ///
+ ///
+ /// The server authentication options, or to defer
+ /// configuration. When null, the first
+ /// call on a session built from this 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 Create(SslServerAuthenticationOptions? options)
+ {
+ SslAuthenticationOptions bag = new SslAuthenticationOptions();
+ if (options is null)
+ {
+ bag.IsServer = true;
+ return new TlsContext(bag, ownsOptions: true, hasServerOptions: false);
+ }
+
+ bag.UpdateOptions(options);
+ return new TlsContext(bag, ownsOptions: true, hasServerOptions: 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 Create(SslClientAuthenticationOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ SslAuthenticationOptions bag = new SslAuthenticationOptions();
+ bag.UpdateOptions(options);
+ return new TlsContext(bag, ownsOptions: true, hasServerOptions: 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, ownsOptions: false, hasServerOptions: sharedOptions.IsServer);
+ }
+
+ public void Dispose()
+ {
+ if (_ownsOptions)
+ {
+ CredentialsHandle?.Dispose();
+ CredentialsHandle = null;
+ _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..2cb95afa725962
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs
@@ -0,0 +1,61 @@
+// 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
+{
+ ///
+ /// Outcome of a non-blocking TLS operation on .
+ /// Provider-opaque; the same values apply across OpenSSL, Schannel, and the
+ /// managed implementation.
+ ///
+ public enum TlsOperationStatus
+ {
+ /// The call made forward progress.
+ Complete = 0,
+
+ /// The session needs more ciphertext from the peer to make progress.
+ WantRead = 1,
+
+ ///
+ /// The session has ciphertext to send. Call
+ /// (and send the bytes to the peer) before retrying the operation.
+ ///
+ WantWrite = 2,
+
+ ///
+ /// The transport is gone or close_notify was received. Dispose the session.
+ ///
+ Closed = 3,
+
+ ///
+ /// The session requires a client certificate (or a new selection) before it can
+ /// proceed. The caller should update options as needed
+ /// and call again with empty input.
+ ///
+ WantCredentials = 4,
+
+ ///
+ /// The peer presented a certificate and the TLS state machine has paused so the
+ /// caller can validate it. The caller should 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. Until the
+ /// result is set, and throw.
+ ///
+ NeedsCertificateValidation = 5,
+
+ ///
+ /// Server-side only. The peer's ClientHello has been received but no server options
+ /// were supplied when the was created. Inspect
+ /// , supply the resolved options via
+ /// , and call
+ /// again with the same input.
+ ///
+ NeedsServerOptions = 6,
+ }
+}
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..66276738062f48
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Stub.cs
@@ -0,0 +1,76 @@
+// 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;
+
+namespace System.Net.Security
+{
+ ///
+ /// Stub implementation used on platforms where TlsSession is not yet supported.
+ /// All operations throw .
+ ///
+ public sealed class TlsSession : IDisposable
+ {
+ private TlsSession() { }
+
+ public bool IsServer => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+ 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 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 static TlsSession Create(TlsContext context) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus ProcessHandshake(ReadOnlySpan input, Span output, out int consumed, out int produced) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Encrypt(ReadOnlySpan plaintext, Span ciphertext, out int consumed, out int produced) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Decrypt(ReadOnlySpan ciphertext, Span plaintext, out int consumed, out int produced) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus DrainPendingOutput(Span ciphertext, out int produced) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public TlsOperationStatus Shutdown(Span ciphertext, out int produced) =>
+ 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 SetServerOptions(SslServerAuthenticationOptions options) =>
+ 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 TlsOperationStatus RequestClientCertificate(Span ciphertext, out int produced) =>
+ throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported);
+
+ public void Dispose() { }
+ }
+}
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..7048e6a80d155c
--- /dev/null
+++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs
@@ -0,0 +1,1308 @@
+// 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.Diagnostics;
+using System.IO;
+using System.Security.Authentication;
+using System.Security.Authentication.ExtendedProtection;
+using System.Security.Cryptography.X509Certificates;
+
+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.
+ ///
+ ///
+ public sealed class TlsSession : IDisposable
+ {
+ // Matches StreamSizes.Default on Unix; conservative upper bound for a
+ // single TLS record's plaintext payload.
+ internal const int MaxRecordPlaintext = 16354;
+
+ private readonly TlsContext _context;
+ private SafeDeleteSslContext? _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;
+ // 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 bool _disposed;
+ private SslConnectionInfo _connectionInfo;
+ private X509Certificate2? _remoteCertificate;
+ private int _headerSize;
+ private int _trailerSize;
+ private int _maxDataSize = MaxRecordPlaintext;
+
+ private TlsSession(TlsContext context)
+ {
+ _context = context;
+ }
+
+ public static TlsSession Create(TlsContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ TlsSession session = new TlsSession(context);
+
+#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
+ // OpenSSL's CertVerifyCallback must answer synchronously, but a TlsSession
+ // always defers peer-cert validation to its caller. On OpenSSL 3.0+ the
+ // callback uses SSL_set_retry_verify to pause the handshake; on 1.1.x it
+ // accepts the cert and validation runs after the handshake completes.
+ // The SslStream wedge sets its own validator on the shared options bag
+ // before calling Create; the callback gives precedence to a non-null
+ // RemoteCertificateValidator, so the wedge path is unaffected.
+ context.Options.DeferCertificateValidation = true;
+#endif
+
+ return session;
+ }
+
+ // ── State ─────────────────────────────────────────────────────────
+
+ public bool IsServer => _context.IsServer;
+
+ public bool IsHandshakeComplete => _isHandshakeComplete;
+
+ public bool HasPendingOutput => _pendingLength > 0;
+
+ public string? TargetHostName
+ {
+ get => _context.Options.TargetHost;
+ set => _context.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(
+ "AcceptWithDefaultValidation can only be called after ProcessHandshake returned NeedsCertificateValidation.");
+ }
+
+ // 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,
+ _context.Options,
+ _securityContext,
+ ref _remoteCertificate,
+ ref _connectionInfo,
+ _externalPendingCert,
+ chain,
+ trust: null,
+ ref alertToken,
+ out sslPolicyErrors,
+ out _);
+ }
+ finally
+ {
+ chain.Dispose();
+ }
+
+ // 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(
+ "SetRemoteCertificateValidationResult can only be called after ProcessHandshake returned NeedsCertificateValidation.");
+ }
+
+ _externalValidationPending = false;
+ _externalValidationResolved = true;
+
+ 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
+ {
+ _externalValidationFault = new AuthenticationException(SR.net_ssl_io_cert_validation);
+ _externalPendingCert?.Dispose();
+ _externalPendingCert = null;
+ }
+
+ DisposeExternalRemoteCertificates();
+ }
+
+ ///
+ /// Server-side only. The parsed ClientHello information, populated when
+ /// returns
+ /// after the context was
+ /// created with null server options. Returns at all
+ /// other times. The value is cleared after is
+ /// called.
+ ///
+ public SslClientHelloInfo? ClientHelloInfo
+ {
+ get
+ {
+ ThrowIfDisposed();
+ return _clientHelloInfo;
+ }
+ }
+
+ ///
+ /// Server-side only. Supplies the resolved server options when the session is
+ /// suspended on . The next
+ /// call should be invoked with the same input
+ /// buffer (the ClientHello bytes the session returned uncomsumed).
+ ///
+ ///
+ /// Thrown if the session is not currently awaiting server options, or if
+ /// server options were already supplied at creation.
+ ///
+ public void SetServerOptions(SslServerAuthenticationOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ThrowIfDisposed();
+
+ if (!_context.IsServer)
+ {
+ throw new InvalidOperationException("SetServerOptions can only be called on a server-side session.");
+ }
+ if (_context.HasServerOptions)
+ {
+ throw new InvalidOperationException("Server options were already supplied when the TlsContext was created.");
+ }
+ if (_clientHelloInfo is null)
+ {
+ throw new InvalidOperationException("SetServerOptions can only be called after ProcessHandshake returned NeedsServerOptions.");
+ }
+
+ _context.ApplyServerOptions(options);
+#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
+ // Preserve the retry-verify suspension semantics that TlsSession.Create
+ // would have configured up front had server options been available then.
+ _context.Options.DeferCertificateValidation = true;
+#endif
+ _clientHelloInfo = null;
+ }
+
+ 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 _context.Options.CertificateContext?.TargetCertificate;
+ }
+
+ if (_securityContext == null || _securityContext.IsInvalid)
+ {
+ return null;
+ }
+
+ if (!CertificateValidationPal.IsLocalCertificateUsed(_context.CredentialsHandle, _securityContext))
+ {
+ return null;
+ }
+
+ return _context.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 ─────────────────────────────────────────────────────
+
+ public TlsOperationStatus ProcessHandshake(
+ ReadOnlySpan input,
+ Span output,
+ out int consumed,
+ out int produced)
+ {
+ ThrowIfDisposed();
+ consumed = 0;
+ produced = 0;
+
+ if (_externalValidationFault is not null)
+ {
+ throw _externalValidationFault;
+ }
+
+ if (_externalValidationPending)
+ {
+ return TlsOperationStatus.NeedsCertificateValidation;
+ }
+
+ if (_clientHelloInfo is not null)
+ {
+ // The caller previously saw NeedsServerOptions but hasn't supplied options yet.
+ return TlsOperationStatus.NeedsServerOptions;
+ }
+
+ 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)
+ {
+ produced = DrainTo(output);
+ return _pendingLength > 0 ? TlsOperationStatus.WantWrite : 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.
+ bool isInitialClientCall = !_context.IsServer && _securityContext is null;
+ if (!isInitialClientCall)
+ {
+ if (input.Length < TlsFrameHelper.HeaderSize)
+ {
+ return TlsOperationStatus.WantRead;
+ }
+
+ TlsFrameHeader frameHeader = default;
+ if (!TlsFrameHelper.TryGetFrameHeader(input, ref frameHeader))
+ {
+ throw new IOException(SR.net_io_decrypt);
+ }
+
+ if (input.Length < frameHeader.Length)
+ {
+ return TlsOperationStatus.WantRead;
+ }
+ }
+
+ ProtocolToken token = default;
+ token.RentBuffer = true;
+ try
+ {
+ if (_context.IsServer)
+ {
+ // 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)
+ {
+ // Deferred options: parse ClientHello and suspend so the caller can
+ // supply server options via SetServerOptions. Leave input unconsumed
+ // (consumed = 0); the caller re-feeds the same bytes after resuming.
+ if (!_context.HasServerOptions)
+ {
+ SslClientHelloInfo? parsed = TryParseClientHello(input);
+ if (parsed is null)
+ {
+ return TlsOperationStatus.WantRead;
+ }
+
+ _clientHelloInfo = parsed;
+ return TlsOperationStatus.NeedsServerOptions;
+ }
+
+ bool needsCertResolution =
+ _context.Options.CertificateContext is null &&
+ _context.Options.ServerCertSelectionDelegate is not null;
+
+ if (needsCertResolution && !ResolveServerCertificateFromClientHello(input))
+ {
+ // Need more bytes to parse the ClientHello (and run the
+ // ServerCertificateSelectionCallback).
+ return TlsOperationStatus.WantRead;
+ }
+ }
+
+ EnsureCredentialsAcquired();
+
+ token = SslStreamPal.AcceptSecurityContext(
+ ref _context.CredentialsHandle,
+ ref _securityContext,
+ input,
+ out consumed,
+ _context.Options);
+ }
+ else
+ {
+ EnsureCredentialsAcquired();
+
+ string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost);
+ token = SslStreamPal.InitializeSecurityContext(
+ ref _context.CredentialsHandle,
+ ref _securityContext,
+ hostName,
+ input,
+ out consumed,
+ _context.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));
+ }
+
+ if (token.Failed)
+ {
+ throw new AuthenticationException(SR.net_auth_SSPI, token.GetException());
+ }
+
+ bool done = token.Status.ErrorCode == SecurityStatusPalErrorCode.OK;
+ bool needsCredentials = token.Status.ErrorCode == SecurityStatusPalErrorCode.CredentialsNeeded;
+ bool needsCertValidation = token.Status.ErrorCode == SecurityStatusPalErrorCode.NeedsRemoteCertificateValidation;
+
+ if (done)
+ {
+ OnHandshakeCompleted();
+ }
+ else if (needsCertValidation)
+ {
+ // OpenSSL 3.0+ retry-verify: the handshake paused inside
+ // the verify callback. Capture the peer cert + chain so the
+ // caller can validate, then return NeedsCertificateValidation.
+ CaptureRemoteCertificateForExternalValidation();
+ }
+
+ if (_pendingLength > 0)
+ {
+ produced = DrainTo(output);
+ if (_pendingLength > 0)
+ {
+ return TlsOperationStatus.WantWrite;
+ }
+ }
+
+ if (done)
+ {
+ return _externalValidationPending
+ ? TlsOperationStatus.NeedsCertificateValidation
+ : TlsOperationStatus.Complete;
+ }
+
+ if (needsCertValidation)
+ {
+ return TlsOperationStatus.NeedsCertificateValidation;
+ }
+
+ if (needsCredentials)
+ {
+ return TlsOperationStatus.WantCredentials;
+ }
+
+ // 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 (consumed > 0 && consumed < input.Length)
+ {
+ return TlsOperationStatus.Complete;
+ }
+
+ return TlsOperationStatus.WantRead;
+ }
+ finally
+ {
+ token.ReleasePayload();
+ }
+ }
+
+ public TlsOperationStatus Encrypt(
+ ReadOnlySpan plaintext,
+ Span ciphertext,
+ out int consumed,
+ out int produced)
+ {
+ ThrowIfDisposed();
+ ThrowIfPendingExternalValidation();
+ consumed = 0;
+ produced = 0;
+
+ if (!_isHandshakeComplete)
+ {
+ throw new InvalidOperationException("Handshake has not yet completed.");
+ }
+
+ if (_pendingLength > 0)
+ {
+ produced = DrainTo(ciphertext);
+ return _pendingLength > 0 ? TlsOperationStatus.WantWrite : 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));
+ }
+
+ consumed = 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);
+ }
+
+ produced = DrainTo(ciphertext);
+ return _pendingLength > 0 ? TlsOperationStatus.WantWrite : TlsOperationStatus.Complete;
+ }
+
+ // ── Decrypt ───────────────────────────────────────────────────────
+
+ public TlsOperationStatus Decrypt(
+ ReadOnlySpan ciphertext,
+ Span plaintext,
+ out int consumed,
+ out int produced)
+ {
+ ThrowIfDisposed();
+ ThrowIfPendingExternalValidation();
+ consumed = 0;
+ produced = 0;
+
+ if (!_isHandshakeComplete)
+ {
+ throw new InvalidOperationException("Handshake has not yet completed.");
+ }
+
+ if (_pendingLength > 0)
+ {
+ // Caller must drain before we accept new input.
+ return TlsOperationStatus.WantWrite;
+ }
+
+ // Need at least a frame header.
+ if (ciphertext.Length < TlsFrameHelper.HeaderSize)
+ {
+ return TlsOperationStatus.WantRead;
+ }
+
+ 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 TlsOperationStatus.WantRead;
+ }
+
+ // 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),
+ out int outOffset,
+ out int outCount);
+
+ switch (status.ErrorCode)
+ {
+ case SecurityStatusPalErrorCode.OK:
+ consumed = frameSize;
+ if (outCount > plaintext.Length)
+ {
+ throw new InvalidOperationException(
+ $"Plaintext buffer too small: needed {outCount}, got {plaintext.Length}.");
+ }
+ _decryptScratch.AsSpan(outOffset, outCount).CopyTo(plaintext);
+ produced = outCount;
+ return TlsOperationStatus.Complete;
+
+ case SecurityStatusPalErrorCode.ContextExpired:
+ case SecurityStatusPalErrorCode.ContextExpiredError:
+ consumed = 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.
+ consumed = frameSize;
+ ProcessPostHandshakeMessage(_decryptScratch.AsSpan(outOffset, outCount));
+ // 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));
+ }
+ }
+
+ // ── 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
+ /// .
+ ///
+ ///
+ public TlsOperationStatus RequestClientCertificate(Span ciphertext, out int produced)
+ {
+ ThrowIfDisposed();
+ produced = 0;
+
+ 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 _context.CredentialsHandle,
+ ref _securityContext!,
+ _context.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();
+ }
+ }
+
+ produced = DrainTo(ciphertext);
+ return _pendingLength > 0 ? TlsOperationStatus.WantWrite : TlsOperationStatus.Complete;
+ }
+
+ // ── 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.
+ ///
+ public TlsOperationStatus Shutdown(Span ciphertext, out int produced)
+ {
+ ThrowIfDisposed();
+ produced = 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 _context.CredentialsHandle,
+ ref _securityContext,
+ ReadOnlySpan.Empty,
+ out _,
+ _context.Options);
+ }
+ else
+ {
+ string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost);
+ token = SslStreamPal.InitializeSecurityContext(
+ ref _context.CredentialsHandle,
+ ref _securityContext,
+ hostName,
+ ReadOnlySpan.Empty,
+ out _,
+ _context.Options);
+ }
+
+ if (token.Size > 0)
+ {
+ Debug.Assert(token.Payload != null);
+ AppendPending(new ReadOnlySpan(token.Payload, 0, token.Size));
+ }
+ }
+ finally
+ {
+ token.ReleasePayload();
+ }
+ }
+
+ produced = DrainTo(ciphertext);
+ return _pendingLength > 0 ? TlsOperationStatus.WantWrite : TlsOperationStatus.Closed;
+ }
+
+ // ── Pending output ────────────────────────────────────────────────
+
+ public TlsOperationStatus DrainPendingOutput(Span ciphertext, out int produced)
+ {
+ ThrowIfDisposed();
+ produced = DrainTo(ciphertext);
+ return _pendingLength > 0 ? TlsOperationStatus.WantWrite : 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);
+
+ // 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)
+ {
+ TlsFrameHelper.TlsFrameInfo frameInfo = default;
+ if (!TlsFrameHelper.TryGetFrameInfo(input, ref frameInfo))
+ {
+ return null;
+ }
+
+ if (frameInfo.HandshakeType != TlsHandshakeType.ClientHello)
+ {
+ return null;
+ }
+
+ 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))
+ {
+ _context.Options.TargetHost = frameInfo.TargetName;
+ }
+
+ ServerCertificateSelectionCallback? selector = _context.Options.ServerCertSelectionDelegate;
+ if (selector is null || _context.Options.CertificateContext is not null)
+ {
+ return true;
+ }
+
+ X509Certificate? selected = selector(this, _context.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);
+ }
+
+ _context.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 SafeDeleteSslContext? SecurityContext => _securityContext;
+ internal TlsContext Context => _context;
+ internal SafeFreeCredentials? CredentialsHandle
+ {
+ get => _context.CredentialsHandle;
+ set => _context.CredentialsHandle = value;
+ }
+
+ // 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 consumed)
+ {
+ ThrowIfDisposed();
+
+ ProtocolToken token;
+ if (_context.IsServer)
+ {
+ token = SslStreamPal.AcceptSecurityContext(
+ ref _context.CredentialsHandle,
+ ref _securityContext,
+ input,
+ out consumed,
+ _context.Options);
+ }
+ else
+ {
+ string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost);
+ token = SslStreamPal.InitializeSecurityContext(
+ ref _context.CredentialsHandle,
+ ref _securityContext,
+ hostName,
+ input,
+ out consumed,
+ _context.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).
+ // Skip when the peer does not present a certificate AND validation isn't
+ // mandatory (client always validates the server; server only when
+ // ClientCertificateRequired).
+ bool needsValidation = !_suppressInternalCertificateValidation &&
+ (!_context.IsServer || _context.Options.RemoteCertRequired);
+ if (!needsValidation)
+ {
+ return;
+ }
+
+ // If the caller already resolved validation mid-handshake (OpenSSL 3.0+
+ // retry-verify path), don't re-suspend here.
+ if (_externalValidationResolved)
+ {
+ return;
+ }
+
+ CaptureRemoteCertificateForExternalValidation();
+ }
+
+ // Capture the peer certificate and chain so the caller can perform validation
+ // out of band. Used both when the handshake completes (1.1.x: callback already
+ // accepted) and when the handshake pauses mid-flight via SSL_set_retry_verify
+ // (OpenSSL 3.0+). 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, _context.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(_context.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 _context.CredentialsHandle,
+ ref _securityContext,
+ data,
+ out _,
+ _context.Options);
+ }
+ else
+ {
+ string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost);
+ token = SslStreamPal.InitializeSecurityContext(
+ ref _context.CredentialsHandle,
+ ref _securityContext,
+ hostName,
+ data,
+ out _,
+ _context.Options);
+ }
+
+ if (token.Size > 0)
+ {
+ Debug.Assert(token.Payload != null);
+ AppendPending(new ReadOnlySpan(token.Payload, 0, token.Size));
+ }
+ }
+ finally
+ {
+ token.ReleasePayload();
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+ _disposed = true;
+
+ DisposeExternalRemoteCertificates();
+ _externalPendingCert?.Dispose();
+ _externalPendingCert = null;
+
+ _securityContext?.Dispose();
+ _securityContext = null;
+
+ if (_pending != null)
+ {
+ ArrayPool.Shared.Return(_pending);
+ _pending = null;
+ }
+ if (_decryptScratch != null)
+ {
+ ArrayPool.Shared.Return(_decryptScratch);
+ _decryptScratch = null;
+ }
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs b/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs
index f4d79d578c8f2c..5890b449a9b989 100644
--- a/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs
+++ b/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs
@@ -34,6 +34,7 @@ internal enum SecurityStatusPalErrorCode
Renegotiate,
TryAgain,
HandshakeStarted,
+ NeedsRemoteCertificateValidation,
// Errors
OutOfMemory,
diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj b/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj
index e8c82b958c5b6b..0424165dc3d075 100644
--- a/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj
+++ b/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj
@@ -36,6 +36,8 @@
+
diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs
new file mode 100644
index 00000000000000..a68584c28d8bca
--- /dev/null
+++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs
@@ -0,0 +1,1239 @@
+// 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.IO;
+using System.Net.Sockets;
+using System.Net.Test.Common;
+using System.Security.Authentication;
+using System.Security.Authentication.ExtendedProtection;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+using TestCertificates = System.Net.Test.Common.Configuration.Certificates;
+
+namespace System.Net.Security.Tests
+{
+ [PlatformSpecific(TestPlatforms.Linux | TestPlatforms.FreeBSD | TestPlatforms.Windows)]
+ public class TlsSessionTests
+ {
+ private const int CipherBufSize = 32 * 1024;
+
+ [Fact]
+ public async Task ServerSession_AgainstSslStreamClient_HandshakeAndPingPong_Succeeds()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams();
+ using (clientStream)
+ using (serverStream)
+ using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ ClientCertificateRequired = false,
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate,
+ });
+
+ Task serverHandshake = DriveHandshakeAsync(session, serverStream);
+
+ await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30));
+
+ Assert.True(session.IsHandshakeComplete);
+ Assert.True(clientSsl.IsAuthenticated);
+ Assert.True(session.NegotiatedProtocol is SslProtocols.Tls12 or SslProtocols.Tls13);
+
+ // Steady-state ping-pong.
+ byte[] ping = "PING"u8.ToArray();
+ byte[] pong = "PONG"u8.ToArray();
+
+ // Client → server
+ Task clientWrite = clientSsl.WriteAsync(ping).AsTask();
+ byte[] received = await ReadOnePlaintextRecordAsync(session, serverStream, expectedLength: ping.Length);
+ await clientWrite;
+ Assert.Equal(ping, received);
+
+ // Server → client
+ await WritePlaintextAsync(session, serverStream, pong);
+ byte[] back = new byte[pong.Length];
+ int n = 0;
+ while (n < back.Length)
+ {
+ int r = await clientSsl.ReadAsync(back.AsMemory(n));
+ Assert.True(r > 0, "Client read returned 0 unexpectedly.");
+ n += r;
+ }
+ Assert.Equal(pong, back);
+ }
+ }
+
+ [Fact]
+ public void TlsContext_NullServerOptions_DefersResolution()
+ {
+ // Null server options are allowed: the server-side session parses the ClientHello
+ // and suspends on NeedsServerOptions so the caller can resolve options via
+ // SetServerOptions (e.g. SNI-driven). Only the client overload rejects null.
+ using TlsContext ctx = TlsContext.Create((SslServerAuthenticationOptions)null!);
+ Assert.True(ctx.IsServer);
+
+ Assert.Throws(() => TlsContext.Create((SslClientAuthenticationOptions)null!));
+ }
+
+ [Fact]
+ public void TlsSession_OperationsBeforeHandshake_Throw()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions { ServerCertificate = serverCert });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ byte[] buf = new byte[16];
+ Assert.Throws(() => session.Encrypt(buf, buf, out _, out _));
+ Assert.Throws(() => session.Decrypt(buf, buf, out _, out _));
+ }
+
+ [Fact]
+ public async Task ServerSession_Shutdown_DeliversCloseNotifyToSslStreamClient()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams();
+ using (clientStream)
+ using (serverStream)
+ using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ ClientCertificateRequired = false,
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate,
+ });
+ Task serverHandshake = DriveHandshakeAsync(session, serverStream);
+ await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30));
+
+ byte[] scratch = ArrayPool.Shared.Rent(CipherBufSize);
+ try
+ {
+ TlsOperationStatus status;
+ do
+ {
+ status = session.Shutdown(scratch, out int produced);
+ if (produced > 0)
+ {
+ await serverStream.WriteAsync(scratch.AsMemory(0, produced));
+ await serverStream.FlushAsync();
+ }
+ }
+ while (status == TlsOperationStatus.WantWrite);
+
+ Assert.Equal(TlsOperationStatus.Closed, status);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(scratch);
+ }
+
+ // Client should observe EOF (close_notify) on the next read.
+ byte[] buf = new byte[16];
+ int n = await clientSsl.ReadAsync(buf).AsTask().WaitAsync(TimeSpan.FromSeconds(30));
+ Assert.Equal(0, n);
+ }
+ }
+
+ [Fact]
+ public async Task ServerSession_MutualAuth_InitialHandshake_InvokesValidator()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ using X509Certificate2 clientCert = TestCertificates.GetClientCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ int validatorCalls = 0;
+ X509Certificate2? observedClientCert = null;
+
+ (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams();
+ using (clientStream)
+ using (serverStream)
+ using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ ClientCertificateRequired = true,
+ RemoteCertificateValidationCallback = (s, c, ch, e) =>
+ {
+ validatorCalls++;
+ observedClientCert = c as X509Certificate2;
+ return true;
+ },
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ ClientCertificates = new X509CertificateCollection { clientCert },
+ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate,
+ });
+ Task serverHandshake = DriveHandshakeAsync(session, serverStream);
+ await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30));
+
+ Assert.True(session.IsHandshakeComplete);
+ Assert.Equal(1, validatorCalls);
+ Assert.NotNull(observedClientCert);
+ Assert.Equal(clientCert.Thumbprint, observedClientCert!.Thumbprint);
+
+ using X509Certificate2? remote = session.GetRemoteCertificate();
+ Assert.NotNull(remote);
+ Assert.Equal(clientCert.Thumbprint, remote!.Thumbprint);
+ }
+ }
+
+ [Fact]
+ public async Task ServerSession_ChannelBinding_MatchesSslStreamClient()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams();
+ using (clientStream)
+ using (serverStream)
+ using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = SslProtocols.Tls12,
+ ClientCertificateRequired = false,
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12,
+ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate,
+ });
+ Task serverHandshake = DriveHandshakeAsync(session, serverStream);
+ await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30));
+
+ using ChannelBinding? serverBinding = session.GetChannelBinding(ChannelBindingKind.Unique);
+ using ChannelBinding? clientBinding = clientSsl.TransportContext?.GetChannelBinding(ChannelBindingKind.Unique);
+
+ Assert.NotNull(serverBinding);
+ Assert.NotNull(clientBinding);
+ Assert.False(serverBinding!.IsInvalid);
+ Assert.Equal(clientBinding!.Size, serverBinding.Size);
+
+ byte[] s = new byte[serverBinding.Size];
+ byte[] c = new byte[clientBinding.Size];
+ System.Runtime.InteropServices.Marshal.Copy(serverBinding.DangerousGetHandle(), s, 0, s.Length);
+ System.Runtime.InteropServices.Marshal.Copy(clientBinding.DangerousGetHandle(), c, 0, c.Length);
+ Assert.Equal(c, s);
+ }
+ }
+
+ // Pure in-memory TlsSession <-> TlsSession exchange. The test runs both
+ // TLS 1.2 and TLS 1.3 to exercise the post-handshake record path.
+ //
+ // TLS 1.3 note: after the server consumes the client Finished, OpenSSL
+ // emits one or more NewSessionTicket records on the server->client side.
+ // The client MUST process those (via Decrypt) before its first Encrypt
+ // call -- otherwise OpenSSL on the client side has not yet finalized
+ // its write-key transition from client_handshake_traffic_secret to
+ // client_application_traffic_secret, and the server (which has already
+ // transitioned its read key) rejects the resulting ciphertext as
+ // "decryption failed or bad record mac". In real network deployments
+ // the client's receive pump consumes these bytes naturally; in this
+ // synchronous in-memory loop we drain them explicitly below.
+ [Theory]
+ [InlineData(SslProtocols.Tls12)]
+ [InlineData(SslProtocols.Tls13)]
+ public void TwoSessions_HandshakeAndPingPong_InMemory_Succeeds(SslProtocols protocols)
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ using TlsContext serverCtx = TlsContext.Create(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = protocols,
+ ClientCertificateRequired = false,
+ });
+ using TlsContext clientCtx = TlsContext.Create(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = protocols,
+ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate,
+ });
+
+ using TlsSession server = TlsSession.Create(serverCtx);
+ using TlsSession client = TlsSession.Create(clientCtx);
+
+ byte[] cToS = new byte[CipherBufSize]; int cToSLen = 0;
+ byte[] sToC = new byte[CipherBufSize]; int sToCLen = 0;
+
+ for (int round = 0; round < 32 && (!client.IsHandshakeComplete || !server.IsHandshakeComplete); round++)
+ {
+ StepHandshakeInMemory(client, sToC, ref sToCLen, cToS, ref cToSLen);
+ StepHandshakeInMemory(server, cToS, ref cToSLen, sToC, ref sToCLen);
+ }
+
+ Assert.True(client.IsHandshakeComplete);
+ Assert.True(server.IsHandshakeComplete);
+ Assert.Equal(client.NegotiatedProtocol, server.NegotiatedProtocol);
+ Assert.Equal(protocols, client.NegotiatedProtocol);
+
+ // Drain any leftover server->client post-handshake bytes (TLS 1.3 NST)
+ // through the client before exchanging app data. See comment above.
+ DrainAppDataInto(client, sToC, ref sToCLen);
+
+ byte[] ping = "PING from client"u8.ToArray();
+ byte[] pong = "PONG from server"u8.ToArray();
+
+ Assert.Equal(ping, RoundtripRecord(client, server, ping));
+ Assert.Equal(pong, RoundtripRecord(server, client, pong));
+ }
+
+ private static void DrainAppDataInto(TlsSession session, byte[] cipher, ref int cipherLen)
+ {
+ byte[] scratch = new byte[CipherBufSize];
+ while (cipherLen > 0)
+ {
+ session.Decrypt(
+ cipher.AsSpan(0, cipherLen), scratch, out int consumed, out _);
+ if (consumed == 0)
+ {
+ break;
+ }
+ if (consumed < cipherLen)
+ {
+ Buffer.BlockCopy(cipher, consumed, cipher, 0, cipherLen - consumed);
+ }
+ cipherLen -= consumed;
+ }
+ }
+
+ // TlsSession driven against a real non-blocking Socket (Socket.Blocking=false).
+ // The peer is a plain SslStream over a NetworkStream. This exercises the
+ // "give me raw socket bytes, I don't care about your I/O model" contract:
+ // TlsSession sees only Send/Receive returning WouldBlock and never blocks
+ // on I/O itself.
+ [Fact]
+ public async Task ServerSession_OnNonBlockingSocket_AgainstSslStreamClient_Succeeds()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ (Socket clientSocket, Socket serverSocket) = await CreateLoopbackSocketPairAsync();
+ serverSocket.Blocking = false;
+
+ using (clientSocket)
+ using (serverSocket)
+ using (NetworkStream clientNetStream = new NetworkStream(clientSocket, ownsSocket: false))
+ using (SslStream clientSsl = new SslStream(clientNetStream, leaveInnerStreamOpen: true, TestHelper.AllowAnyServerCertificate))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ ClientCertificateRequired = false,
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate,
+ });
+
+ Task serverHandshake = Task.Run(() => DriveHandshakeNonBlocking(session, serverSocket));
+
+ await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30));
+
+ Assert.True(session.IsHandshakeComplete);
+ Assert.True(clientSsl.IsAuthenticated);
+
+ byte[] ping = "PING over non-blocking socket"u8.ToArray();
+ byte[] pong = "PONG over non-blocking socket"u8.ToArray();
+
+ Task clientWrite = clientSsl.WriteAsync(ping).AsTask();
+ byte[] got = await Task.Run(() => ReadOnePlaintextNonBlocking(session, serverSocket, ping.Length))
+ .WaitAsync(TimeSpan.FromSeconds(30));
+ await clientWrite;
+ Assert.Equal(ping, got);
+
+ await Task.Run(() => WritePlaintextNonBlocking(session, serverSocket, pong))
+ .WaitAsync(TimeSpan.FromSeconds(30));
+ byte[] back = new byte[pong.Length];
+ int n = 0;
+ while (n < back.Length)
+ {
+ int r = await clientSsl.ReadAsync(back.AsMemory(n));
+ Assert.True(r > 0);
+ n += r;
+ }
+ Assert.Equal(pong, back);
+ }
+ }
+
+ private static async Task<(Socket Client, Socket Server)> CreateLoopbackSocketPairAsync()
+ {
+ using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ listener.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
+ listener.Listen(1);
+
+ Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ Task acceptTask = listener.AcceptAsync();
+ await client.ConnectAsync(listener.LocalEndPoint!);
+ Socket server = await acceptTask;
+ client.NoDelay = true;
+ server.NoDelay = true;
+ return (client, server);
+ }
+
+ private static void DriveHandshakeNonBlocking(TlsSession session, Socket socket)
+ {
+ byte[] netIn = new byte[CipherBufSize];
+ byte[] netOut = new byte[CipherBufSize];
+ int inUsed = 0;
+
+ while (!session.IsHandshakeComplete)
+ {
+ TlsOperationStatus status = session.ProcessHandshake(
+ 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.WantWrite:
+ DrainPending(session, socket, netOut);
+ continue;
+ case TlsOperationStatus.WantRead:
+ inUsed += NonBlockingReceiveSome(socket, netIn, inUsed);
+ continue;
+ case TlsOperationStatus.Closed:
+ throw new IOException("Peer closed connection during handshake.");
+ }
+ }
+ }
+
+ private static byte[] ReadOnePlaintextNonBlocking(TlsSession session, Socket socket, int expectedLength)
+ {
+ byte[] netIn = new byte[CipherBufSize];
+ byte[] plain = new byte[CipherBufSize];
+ int inUsed = 0;
+
+ while (true)
+ {
+ TlsOperationStatus status = session.Decrypt(
+ 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)
+ {
+ Assert.Equal(expectedLength, produced);
+ return plain.AsSpan(0, produced).ToArray();
+ }
+
+ switch (status)
+ {
+ case TlsOperationStatus.Complete:
+ continue;
+ case TlsOperationStatus.WantRead:
+ inUsed += NonBlockingReceiveSome(socket, netIn, inUsed);
+ continue;
+ case TlsOperationStatus.WantWrite:
+ DrainPending(session, socket, new byte[CipherBufSize]);
+ continue;
+ case TlsOperationStatus.Closed:
+ throw new IOException("Connection closed while reading plaintext.");
+ }
+ }
+ }
+
+ private static void WritePlaintextNonBlocking(TlsSession session, Socket socket, ReadOnlySpan data)
+ {
+ byte[] outBuf = new byte[CipherBufSize];
+ int sent = 0;
+ while (sent < data.Length)
+ {
+ TlsOperationStatus status = session.Encrypt(
+ data.Slice(sent), outBuf, out int consumed, out int produced);
+ sent += consumed;
+ if (produced > 0)
+ {
+ NonBlockingSendAll(socket, outBuf, 0, produced);
+ }
+ if (status == TlsOperationStatus.WantWrite)
+ {
+ DrainPending(session, socket, outBuf);
+ }
+ }
+ }
+
+ private static void DrainPending(TlsSession session, Socket socket, byte[] scratch)
+ {
+ while (session.HasPendingOutput)
+ {
+ 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);
+ offset += n;
+ count -= n;
+ }
+ catch (SocketException ex) when (ex.SocketErrorCode == SocketError.WouldBlock)
+ {
+ 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);
+ if (n == 0)
+ {
+ throw new IOException("Unexpected EOF.");
+ }
+ return n;
+ }
+ catch (SocketException ex) when (ex.SocketErrorCode == SocketError.WouldBlock)
+ {
+ socket.Poll(-1, SelectMode.SelectRead);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task ClientSession_AgainstSslStreamServer_HandshakeAndPingPong_Succeeds()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams();
+ using (clientStream)
+ using (serverStream)
+ using (SslStream serverSsl = new SslStream(serverStream, leaveInnerStreamOpen: false))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate,
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task serverHandshake = serverSsl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ ClientCertificateRequired = false,
+ });
+ Task clientHandshake = DriveHandshakeAsync(session, clientStream);
+
+ await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30));
+
+ Assert.True(session.IsHandshakeComplete);
+ Assert.True(serverSsl.IsAuthenticated);
+ Assert.True(session.NegotiatedProtocol is SslProtocols.Tls12 or SslProtocols.Tls13);
+
+ byte[] ping = "PING"u8.ToArray();
+ byte[] pong = "PONG"u8.ToArray();
+
+ await WritePlaintextAsync(session, clientStream, ping);
+ byte[] gotByServer = new byte[ping.Length];
+ int n = 0;
+ while (n < gotByServer.Length)
+ {
+ int r = await serverSsl.ReadAsync(gotByServer.AsMemory(n));
+ Assert.True(r > 0);
+ n += r;
+ }
+ Assert.Equal(ping, gotByServer);
+
+ Task serverWrite = serverSsl.WriteAsync(pong).AsTask();
+ byte[] gotByClient = await ReadOnePlaintextRecordAsync(session, clientStream, expectedLength: pong.Length);
+ await serverWrite;
+ Assert.Equal(pong, gotByClient);
+ }
+ }
+
+ // ── External certificate validation ───────────────────────────────
+
+ [Fact]
+ public async Task ClientSession_ExternalCertificateValidation_SuspendsAndAccepts()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams();
+ using (clientStream)
+ using (serverStream)
+ using (SslStream serverSsl = new SslStream(serverStream, leaveInnerStreamOpen: false))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ // Intentionally no RemoteCertificateValidationCallback — caller drives validation externally.
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task serverHandshake = serverSsl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ ClientCertificateRequired = false,
+ });
+
+ bool suspensionObserved = false;
+ X509Certificate2? observedRemoteCert = null;
+ Task clientHandshake = Task.Run(async () =>
+ {
+ await DriveHandshakeWithExternalValidationAsync(
+ session, clientStream,
+ onSuspend: () =>
+ {
+ suspensionObserved = true;
+ observedRemoteCert = session.GetRemoteCertificate();
+ session.SetRemoteCertificateValidationResult(SslPolicyErrors.None);
+ });
+ });
+
+ await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30));
+
+ Assert.True(suspensionObserved, "Caller never observed NeedsCertificateValidation.");
+ Assert.NotNull(observedRemoteCert);
+ Assert.Equal(serverCert.Thumbprint, observedRemoteCert!.Thumbprint);
+ Assert.True(session.IsHandshakeComplete);
+ Assert.True(serverSsl.IsAuthenticated);
+
+ // After accepting, Encrypt/Decrypt must work normally.
+ byte[] ping = "PING external"u8.ToArray();
+ await WritePlaintextAsync(session, clientStream, ping);
+ byte[] got = new byte[ping.Length];
+ int n = 0;
+ while (n < got.Length)
+ {
+ int r = await serverSsl.ReadAsync(got.AsMemory(n));
+ Assert.True(r > 0);
+ n += r;
+ }
+ Assert.Equal(ping, got);
+
+ observedRemoteCert?.Dispose();
+ }
+ }
+
+ [Fact]
+ public async Task ClientSession_ExternalCertificateValidation_AcceptWithDefaultValidation_FailsOnUntrustedCert()
+ {
+ // The test cert chain isn't installed in the system trust store, so the default
+ // validation policy must report at least RemoteCertificateChainErrors.
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams();
+ using (clientStream)
+ using (serverStream)
+ using (SslStream serverSsl = new SslStream(serverStream, leaveInnerStreamOpen: false))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task serverHandshake = serverSsl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ ClientCertificateRequired = false,
+ });
+
+ SslPolicyErrors observedErrors = SslPolicyErrors.None;
+ Task clientHandshake = Task.Run(async () =>
+ {
+ await DriveHandshakeWithExternalValidationAsync(
+ session, clientStream,
+ onSuspend: () =>
+ {
+ observedErrors = session.AcceptWithDefaultValidation();
+ });
+ });
+
+ // The server-side handshake will complete (OpenSSL accepted the cert in the
+ // CertVerifyCallback), but the client side rejects post-hoc, so any subsequent
+ // app-data exchange throws on the client.
+ await serverHandshake.WaitAsync(TimeSpan.FromSeconds(30));
+ await clientHandshake.WaitAsync(TimeSpan.FromSeconds(30));
+
+ Assert.NotEqual(SslPolicyErrors.None, observedErrors);
+
+ // Encrypt must now throw because validation reported errors.
+ byte[] plain = "should-fail"u8.ToArray();
+ byte[] ct = new byte[CipherBufSize];
+ Assert.Throws(() =>
+ session.Encrypt(plain, ct, out _, out _));
+ }
+ }
+
+ [Fact]
+ public void TlsSession_ExternalValidation_SetResultBeforeSuspended_Throws()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions { ServerCertificate = serverCert });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Assert.Throws(() =>
+ session.SetRemoteCertificateValidationResult(SslPolicyErrors.None));
+ Assert.Throws(() =>
+ session.AcceptWithDefaultValidation());
+ }
+
+ // Like DriveHandshakeAsync but pauses on NeedsCertificateValidation to invoke the supplied callback.
+ // After the callback runs, the handshake is considered complete (IsHandshakeComplete is already true
+ // at the point the suspension is reported on Unix/OpenSSL).
+ private static async Task DriveHandshakeWithExternalValidationAsync(
+ TlsSession session, Stream transport, Action onSuspend)
+ {
+ byte[] netIn = ArrayPool.Shared.Rent(CipherBufSize);
+ byte[] netOut = ArrayPool.Shared.Rent(CipherBufSize);
+ int inUsed = 0;
+ bool suspended = false;
+
+ try
+ {
+ while (!suspended && !session.IsHandshakeComplete)
+ {
+ TlsOperationStatus status = session.ProcessHandshake(
+ 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)
+ {
+ await transport.WriteAsync(netOut.AsMemory(0, produced));
+ await transport.FlushAsync();
+ }
+
+ switch (status)
+ {
+ case TlsOperationStatus.NeedsCertificateValidation:
+ suspended = true;
+ onSuspend();
+ break;
+
+ case TlsOperationStatus.Complete:
+ continue;
+
+ case TlsOperationStatus.WantWrite:
+ await DrainAsync(session, transport, netOut);
+ continue;
+
+ case TlsOperationStatus.WantRead:
+ int r = await transport.ReadAsync(netIn.AsMemory(inUsed));
+ if (r == 0)
+ {
+ throw new IOException("Unexpected EOF during handshake.");
+ }
+ inUsed += r;
+ continue;
+
+ case TlsOperationStatus.Closed:
+ throw new IOException("Peer closed connection during handshake.");
+ }
+ }
+
+ // Flush anything still pending (e.g. server-emitted NewSessionTickets in TLS 1.3
+ // that arrived after the local handshake reached completion).
+ while (session.HasPendingOutput)
+ {
+ TlsOperationStatus drain = session.DrainPendingOutput(netOut, out int n);
+ if (n > 0)
+ {
+ await transport.WriteAsync(netOut.AsMemory(0, n));
+ await transport.FlushAsync();
+ }
+ if (drain != TlsOperationStatus.WantWrite)
+ {
+ break;
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(netIn);
+ ArrayPool.Shared.Return(netOut);
+ }
+ }
+
+ // ── Helpers ────────────────────────────────────────────────────────
+
+ private static void StepHandshakeInMemory(
+ TlsSession session, byte[] input, ref int inputLen, byte[] output, ref int outputLen)
+ {
+ if (session.IsHandshakeComplete)
+ {
+ return;
+ }
+
+ TlsOperationStatus status = session.ProcessHandshake(
+ input.AsSpan(0, inputLen),
+ output.AsSpan(outputLen),
+ out int consumed,
+ out int produced);
+
+ if (consumed > 0)
+ {
+ if (consumed < inputLen)
+ {
+ Buffer.BlockCopy(input, consumed, input, 0, inputLen - consumed);
+ }
+ inputLen -= consumed;
+ }
+ outputLen += produced;
+
+ while (session.HasPendingOutput)
+ {
+ session.DrainPendingOutput(output.AsSpan(outputLen), out int n);
+ outputLen += n;
+ }
+
+ if (status == TlsOperationStatus.NeedsCertificateValidation)
+ {
+ // In-memory helper: defer to the default validation path (which honors
+ // any RemoteCertificateValidationCallback on the underlying options).
+ session.AcceptWithDefaultValidation();
+ }
+
+ Assert.NotEqual(TlsOperationStatus.Closed, status);
+ }
+
+ private static byte[] RoundtripRecord(TlsSession sender, TlsSession receiver, byte[] plaintext)
+ {
+ byte[] ct = new byte[CipherBufSize];
+ int ctLen = 0;
+ int sent = 0;
+ while (sent < plaintext.Length)
+ {
+ sender.Encrypt(
+ plaintext.AsSpan(sent),
+ ct.AsSpan(ctLen),
+ out int consumed,
+ out int produced);
+ sent += consumed;
+ ctLen += produced;
+ while (sender.HasPendingOutput)
+ {
+ sender.DrainPendingOutput(ct.AsSpan(ctLen), out int n);
+ ctLen += n;
+ }
+ }
+
+ byte[] pt = new byte[CipherBufSize];
+ int ptLen = 0;
+ int ctOff = 0;
+ while (ctOff < ctLen)
+ {
+ receiver.Decrypt(
+ ct.AsSpan(ctOff, ctLen - ctOff),
+ pt.AsSpan(ptLen),
+ out int consumed,
+ out int produced);
+ if (consumed == 0 && produced == 0)
+ {
+ break;
+ }
+ ctOff += consumed;
+ ptLen += produced;
+ }
+ return pt.AsSpan(0, ptLen).ToArray();
+ }
+
+ [Fact]
+ public async Task ServerSession_ApplicationProtocols_NegotiatesAlpn()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams();
+ using (clientStream)
+ using (serverStream)
+ using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ ApplicationProtocols = new List { SslApplicationProtocol.Http2, SslApplicationProtocol.Http11 },
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate,
+ ApplicationProtocols = new List { SslApplicationProtocol.Http11, SslApplicationProtocol.Http2 },
+ });
+ Task serverHandshake = DriveHandshakeAsync(session, serverStream);
+ await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30));
+
+ Assert.True(session.IsHandshakeComplete);
+ Assert.Equal(SslApplicationProtocol.Http2, session.NegotiatedApplicationProtocol);
+ Assert.Equal(SslApplicationProtocol.Http2, clientSsl.NegotiatedApplicationProtocol);
+ }
+ }
+
+ [Fact]
+ public async Task ServerSession_ServerCertificateSelectionCallback_InvokedWithSni()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ string? observedSni = null;
+ int callbackCount = 0;
+
+ (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams();
+ using (clientStream)
+ using (serverStream)
+ using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions
+ {
+ ServerCertificateSelectionCallback = (sender, hostName) =>
+ {
+ callbackCount++;
+ observedSni = hostName;
+ Assert.IsType(sender);
+ return serverCert;
+ },
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate,
+ });
+ Task serverHandshake = DriveHandshakeAsync(session, serverStream);
+ await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30));
+
+ Assert.True(session.IsHandshakeComplete);
+ Assert.Equal(1, callbackCount);
+ Assert.Equal(serverName, observedSni);
+ }
+ }
+
+ [Fact]
+ public async Task ServerSession_RequestClientCertificate_Tls12_ProducesHandshakeBytes()
+ {
+ using X509Certificate2 serverCert = TestCertificates.GetServerCertificate();
+ string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+
+ (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams();
+ using (clientStream)
+ using (serverStream)
+ using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate))
+ {
+ using TlsContext ctx = TlsContext.Create(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = serverCert,
+ EnabledSslProtocols = SslProtocols.Tls12,
+ AllowRenegotiation = true,
+ });
+ using TlsSession session = TlsSession.Create(ctx);
+
+ Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
+ {
+ TargetHost = serverName,
+ EnabledSslProtocols = SslProtocols.Tls12,
+ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate,
+ AllowRenegotiation = true,
+ });
+ Task serverHandshake = DriveHandshakeAsync(session, serverStream);
+ await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30));
+
+ Assert.True(session.IsHandshakeComplete);
+ Assert.Equal(SslProtocols.Tls12, session.NegotiatedProtocol);
+
+ // On TLS 1.2, post-handshake client-cert request is implemented
+ // as a renegotiation initiated by a HelloRequest. We only verify
+ // that the API runs and emits handshake bytes; driving the
+ // exchange back through SslStream is out of scope because the
+ // standalone TlsSession leaves the post-handshake read loop to
+ // the caller.
+ byte[] reneg = ArrayPool.Shared.Rent(CipherBufSize);
+ try
+ {
+ TlsOperationStatus status = session.RequestClientCertificate(reneg, out int produced);
+ Assert.NotEqual(TlsOperationStatus.Closed, status);
+ Assert.True(produced > 0, "RequestClientCertificate should emit a HelloRequest.");
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(reneg);
+ }
+ }
+ }
+
+ private static async Task DriveHandshakeAsync(TlsSession session, Stream transport)
+ {
+ byte[] netIn = ArrayPool.Shared.Rent(CipherBufSize);
+ byte[] netOut = ArrayPool.Shared.Rent(CipherBufSize);
+ int inUsed = 0;
+
+ try
+ {
+ while (!session.IsHandshakeComplete)
+ {
+ TlsOperationStatus status = session.ProcessHandshake(
+ 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)
+ {
+ await transport.WriteAsync(netOut.AsMemory(0, produced));
+ await transport.FlushAsync();
+ }
+
+ switch (status)
+ {
+ case TlsOperationStatus.Complete:
+ continue;
+
+ case TlsOperationStatus.NeedsCertificateValidation:
+ session.AcceptWithDefaultValidation();
+ continue;
+
+ case TlsOperationStatus.WantWrite:
+ await DrainAsync(session, transport, netOut);
+ continue;
+
+ case TlsOperationStatus.WantRead:
+ int r = await transport.ReadAsync(netIn.AsMemory(inUsed));
+ if (r == 0)
+ {
+ throw new IOException("Unexpected EOF during handshake.");
+ }
+ inUsed += r;
+ continue;
+
+ case TlsOperationStatus.Closed:
+ throw new IOException("Peer closed connection during handshake.");
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(netIn);
+ ArrayPool.Shared.Return(netOut);
+ }
+ }
+
+ private static async Task ReadOnePlaintextRecordAsync(
+ TlsSession session, Stream transport, int expectedLength)
+ {
+ byte[] netIn = ArrayPool.Shared.Rent(CipherBufSize);
+ byte[] plain = ArrayPool.Shared.Rent(CipherBufSize);
+ int inUsed = 0;
+
+ try
+ {
+ while (true)
+ {
+ TlsOperationStatus status = session.Decrypt(
+ 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)
+ {
+ Assert.Equal(expectedLength, produced);
+ byte[] result = plain.AsSpan(0, produced).ToArray();
+ return result;
+ }
+
+ switch (status)
+ {
+ case TlsOperationStatus.Complete:
+ continue;
+
+ case TlsOperationStatus.WantRead:
+ int r = await transport.ReadAsync(netIn.AsMemory(inUsed));
+ if (r == 0)
+ {
+ throw new IOException("Unexpected EOF while reading plaintext.");
+ }
+ inUsed += r;
+ continue;
+
+ case TlsOperationStatus.WantWrite:
+ byte[] outBuf = ArrayPool.Shared.Rent(CipherBufSize);
+ try { await DrainAsync(session, transport, outBuf); }
+ finally { ArrayPool.Shared.Return(outBuf); }
+ continue;
+
+ case TlsOperationStatus.Closed:
+ throw new IOException("Connection closed while reading plaintext.");
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(netIn);
+ ArrayPool.Shared.Return(plain);
+ }
+ }
+
+ private static async Task WritePlaintextAsync(TlsSession session, Stream transport, ReadOnlyMemory data)
+ {
+ byte[] outBuf = ArrayPool.Shared.Rent(CipherBufSize);
+ try
+ {
+ int sent = 0;
+ while (sent < data.Length)
+ {
+ TlsOperationStatus status = session.Encrypt(
+ data.Span[sent..],
+ outBuf,
+ out int consumed,
+ out int produced);
+
+ sent += consumed;
+
+ if (produced > 0)
+ {
+ await transport.WriteAsync(outBuf.AsMemory(0, produced));
+ await transport.FlushAsync();
+ }
+
+ if (status == TlsOperationStatus.WantWrite)
+ {
+ await DrainAsync(session, transport, outBuf);
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(outBuf);
+ }
+ }
+
+ private static async Task DrainAsync(TlsSession session, Stream transport, byte[] scratch)
+ {
+ while (session.HasPendingOutput)
+ {
+ TlsOperationStatus s = session.DrainPendingOutput(scratch, out int n);
+ if (n > 0)
+ {
+ await transport.WriteAsync(scratch.AsMemory(0, n));
+ await transport.FlushAsync();
+ }
+ if (s != TlsOperationStatus.WantWrite)
+ {
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Security/tests/UnitTests/Fakes/FakeSslStream.Implementation.cs b/src/libraries/System.Net.Security/tests/UnitTests/Fakes/FakeSslStream.Implementation.cs
index ad47676ffb1636..49a9ba364f1887 100644
--- a/src/libraries/System.Net.Security/tests/UnitTests/Fakes/FakeSslStream.Implementation.cs
+++ b/src/libraries/System.Net.Security/tests/UnitTests/Fakes/FakeSslStream.Implementation.cs
@@ -25,6 +25,7 @@ private class FakeOptions
public LocalCertificateSelectionCallback? CertSelectionDelegate;
public X509RevocationMode CertificateRevocationCheckMode;
public SslStream? SslStream;
+ public SslAuthenticationOptions.VerifyRemoteCertificateCallback? RemoteCertificateValidator;
public void UpdateOptions(SslServerAuthenticationOptions sslServerAuthenticationOptions)
{
diff --git a/src/native/libs/System.Security.Cryptography.Native/entrypoints.c b/src/native/libs/System.Security.Cryptography.Native/entrypoints.c
index 96c2e7bf482dbf..86e66de58f1220 100644
--- a/src/native/libs/System.Security.Cryptography.Native/entrypoints.c
+++ b/src/native/libs/System.Security.Cryptography.Native/entrypoints.c
@@ -412,6 +412,7 @@ static const Entry s_cryptoNative[] =
DllImportEntry(CryptoNative_SslSetSession)
DllImportEntry(CryptoNative_SslSetTlsExtHostName)
DllImportEntry(CryptoNative_SslSetVerifyPeer)
+ DllImportEntry(CryptoNative_SslSetRetryVerify)
DllImportEntry(CryptoNative_SslSetSigalgs)
DllImportEntry(CryptoNative_SslSetClientSigalgs)
DllImportEntry(CryptoNative_SslShutdown)
diff --git a/src/native/libs/System.Security.Cryptography.Native/opensslshim.h b/src/native/libs/System.Security.Cryptography.Native/opensslshim.h
index c07abd0cca1eb2..00cb734d6fdb88 100644
--- a/src/native/libs/System.Security.Cryptography.Native/opensslshim.h
+++ b/src/native/libs/System.Security.Cryptography.Native/opensslshim.h
@@ -91,6 +91,17 @@
void ERR_put_error(int32_t lib, int32_t func, int32_t reason, const char* file, int32_t line);
#endif
+// OpenSSL 3.0+ exposes SSL_set_retry_verify via . We resolve it
+// through the lightup function-pointer table, so undefine the header macro and
+// forward-declare it as a real function so the LIGHTUP_FUNCTION machinery and
+// the SSL_set_retry_verify_ptr alias compile cleanly on both 1.1.x and 3.0+.
+#ifdef SSL_set_retry_verify
+#undef SSL_set_retry_verify
+#endif
+#if OPENSSL_VERSION_NUMBER >= OPENSSL_VERSION_3_0_RTM
+int SSL_set_retry_verify(SSL* ssl);
+#endif
+
// The value -1 has the correct meaning on 1.0.x, but the constant wasn't named.
#ifndef RSA_PSS_SALTLEN_DIGEST
#define RSA_PSS_SALTLEN_DIGEST -1
@@ -736,6 +747,7 @@ extern bool g_libSslUses32BitTime;
REQUIRED_FUNCTION(SSL_set_cipher_list) \
LIGHTUP_FUNCTION(SSL_set_ciphersuites) \
REQUIRED_FUNCTION(SSL_set_connect_state) \
+ LIGHTUP_FUNCTION(SSL_set_retry_verify) \
REQUIRED_FUNCTION(SSL_set_ex_data) \
REQUIRED_FUNCTION(SSL_set_options) \
REQUIRED_FUNCTION(SSL_set_session) \
@@ -1304,6 +1316,7 @@ extern TYPEOF(OPENSSL_gmtime)* OPENSSL_gmtime_ptr;
#define SSL_set_cipher_list SSL_set_cipher_list_ptr
#define SSL_set_ciphersuites SSL_set_ciphersuites_ptr
#define SSL_set_connect_state SSL_set_connect_state_ptr
+#define SSL_set_retry_verify SSL_set_retry_verify_ptr
#define SSL_set_ex_data SSL_set_ex_data_ptr
#define SSL_set_options SSL_set_options_ptr
#define SSL_set_session SSL_set_session_ptr
diff --git a/src/native/libs/System.Security.Cryptography.Native/osslcompat_30.h b/src/native/libs/System.Security.Cryptography.Native/osslcompat_30.h
index ec8d0c43a6f314..e94cb67a162bee 100644
--- a/src/native/libs/System.Security.Cryptography.Native/osslcompat_30.h
+++ b/src/native/libs/System.Security.Cryptography.Native/osslcompat_30.h
@@ -152,4 +152,6 @@ OSSL_STORE_CTX* OSSL_STORE_open_ex(
X509* SSL_get1_peer_certificate(const SSL* ssl);
+int SSL_set_retry_verify(SSL* ssl);
+
int EC_GROUP_get_field_type(const EC_GROUP *group);
diff --git a/src/native/libs/System.Security.Cryptography.Native/pal_ssl.c b/src/native/libs/System.Security.Cryptography.Native/pal_ssl.c
index 0648508b513f16..657eb16ae18a1d 100644
--- a/src/native/libs/System.Security.Cryptography.Native/pal_ssl.c
+++ b/src/native/libs/System.Security.Cryptography.Native/pal_ssl.c
@@ -18,6 +18,10 @@ c_static_assert(PAL_SSL_ERROR_WANT_READ == SSL_ERROR_WANT_READ);
c_static_assert(PAL_SSL_ERROR_WANT_WRITE == SSL_ERROR_WANT_WRITE);
c_static_assert(PAL_SSL_ERROR_SYSCALL == SSL_ERROR_SYSCALL);
c_static_assert(PAL_SSL_ERROR_ZERO_RETURN == SSL_ERROR_ZERO_RETURN);
+#ifndef SSL_ERROR_WANT_RETRY_VERIFY
+#define SSL_ERROR_WANT_RETRY_VERIFY 12
+#endif
+c_static_assert(PAL_SSL_ERROR_WANT_RETRY_VERIFY == SSL_ERROR_WANT_RETRY_VERIFY);
c_static_assert(SSL_CTRL_SET_TLSEXT_STATUS_REQ_TYPE == 65);
c_static_assert(TLSEXT_STATUSTYPE_ocsp == 1);
@@ -507,6 +511,20 @@ int32_t CryptoNative_IsSslStateOK(SSL* ssl)
return SSL_is_init_finished(ssl);
}
+int32_t CryptoNative_SslSetRetryVerify(SSL* ssl)
+{
+ // OpenSSL 3.0+ only. When available, calling this from inside the certificate
+ // verification callback (and returning -1 from the callback) suspends the
+ // handshake so the application can perform validation asynchronously and then
+ // resume by calling SSL_do_handshake again.
+ if (API_EXISTS(SSL_set_retry_verify))
+ {
+ return SSL_set_retry_verify(ssl);
+ }
+
+ return 0;
+}
+
X509* CryptoNative_SslGetPeerCertificate(SSL* ssl)
{
X509* cert = SSL_get1_peer_certificate(ssl);
diff --git a/src/native/libs/System.Security.Cryptography.Native/pal_ssl.h b/src/native/libs/System.Security.Cryptography.Native/pal_ssl.h
index 7ea042b960cd70..6c128ea2f71093 100644
--- a/src/native/libs/System.Security.Cryptography.Native/pal_ssl.h
+++ b/src/native/libs/System.Security.Cryptography.Native/pal_ssl.h
@@ -112,6 +112,7 @@ typedef enum
PAL_SSL_ERROR_WANT_WRITE = 3,
PAL_SSL_ERROR_SYSCALL = 5,
PAL_SSL_ERROR_ZERO_RETURN = 6,
+ PAL_SSL_ERROR_WANT_RETRY_VERIFY = 12,
} SslErrorCode;
// the function pointer definition for the callback used in SslCtxSetAlpnSelectCb
@@ -416,6 +417,12 @@ Shims the SSL_set_verify method.
*/
PALEXPORT void CryptoNative_SslSetVerifyPeer(SSL* ssl, int32_t failIfNoPeerCert);
+/*
+Shims SSL_set_retry_verify (OpenSSL 3.0+). Returns 1 on success, 0 if the
+symbol is unavailable (e.g. on OpenSSL 1.1.x).
+*/
+PALEXPORT int32_t CryptoNative_SslSetRetryVerify(SSL* ssl);
+
/*
Shims SSL_set_ex_data to attach application context.
*/