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. */