From 2e82102abe410c408fee66571b2a20ef0944c15c Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 27 May 2026 02:40:05 +0000 Subject: [PATCH 01/18] TlsSession PoC: non-blocking TLS state machine over OpenSSL PAL Introduces internal-preview public types TlsContext, TlsSession, and TlsOperationStatus exposing a non-blocking, transport-agnostic TLS state machine. The PoC implementation is Linux/FreeBSD-only and reuses the existing SslStreamPal (AcceptSecurityContext / InitializeSecurityContext / EncryptMessage / DecryptMessage). On other platforms the types are present as PlatformNotSupportedException stubs so ApiCompat passes. SslStream is intentionally untouched (Stage 1 of the proposal). Adds TlsSessionTests covering: - Server TlsSession against a real SslStream client (handshake + ping/pong) - ArgumentNullException from TlsContext.Create - InvalidOperationException from Encrypt/Decrypt before handshake --- .../ref/System.Net.Security.cs | 34 ++ .../src/System.Net.Security.csproj | 6 + .../src/System/Net/Security/TlsContext.cs | 48 ++ .../System/Net/Security/TlsOperationStatus.cs | 30 ++ .../System/Net/Security/TlsSession.Stub.cs | 50 ++ .../src/System/Net/Security/TlsSession.cs | 459 ++++++++++++++++++ .../System.Net.Security.Tests.csproj | 2 + .../tests/FunctionalTests/TlsSessionTests.cs | 278 +++++++++++ 8 files changed, 907 insertions(+) create mode 100644 src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs create mode 100644 src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs create mode 100644 src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Stub.cs create mode 100644 src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs create mode 100644 src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs 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..5bf7d44f5ce177 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,40 @@ 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, + } + 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.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 DrainPendingOutput(System.Span ciphertext, out int produced) { throw null; } + public System.Security.Cryptography.X509Certificates.X509Certificate2? GetRemoteCertificate() { 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..0d03e13e56dbce 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,12 @@ + + + + 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..bbf59c5ed9fc9a --- /dev/null +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs @@ -0,0 +1,48 @@ +// 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 +{ + /// + /// Long-lived TLS configuration. Wraps an + /// constructed from either or + /// . Role (client vs. server) is + /// determined by which factory is used. + /// + /// + /// PoC scope: 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 TlsContext(SslAuthenticationOptions options) + { + _options = options; + } + + internal SslAuthenticationOptions Options => _options; + + public bool IsServer => _options.IsServer; + + public static TlsContext Create(SslServerAuthenticationOptions options) + { + ArgumentNullException.ThrowIfNull(options); + SslAuthenticationOptions bag = new SslAuthenticationOptions(); + bag.UpdateOptions(options); + return new TlsContext(bag); + } + + public static TlsContext Create(SslClientAuthenticationOptions options) + { + ArgumentNullException.ThrowIfNull(options); + SslAuthenticationOptions bag = new SslAuthenticationOptions(); + bag.UpdateOptions(options); + return new TlsContext(bag); + } + + public void Dispose() => _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..0eb989e319571b --- /dev/null +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs @@ -0,0 +1,30 @@ +// 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, + } +} 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..8676c038ca6d04 --- /dev/null +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Stub.cs @@ -0,0 +1,50 @@ +// 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.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 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 X509Certificate2? GetRemoteCertificate() => + 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..d9e9a67c6a29d9 --- /dev/null +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -0,0 +1,459 @@ +// 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.Cryptography.X509Certificates; + +namespace System.Net.Security +{ + /// + /// Non-blocking TLS state machine wrapper around the existing + /// . PoC scope: detached mode only (caller owns I/O), + /// Linux-only (OpenSSL). 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 SafeFreeCredentials? _credentialsHandle; + private SafeDeleteSslContext? _securityContext; + + private byte[]? _pending; + private int _pendingOffset; + private int _pendingLength; + + private byte[]? _decryptScratch; + + private bool _isHandshakeComplete; + private bool _disposed; + private SslConnectionInfo _connectionInfo; + + private TlsSession(TlsContext context) + { + _context = context; + } + + public static TlsSession Create(TlsContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsFreeBSD()) + { + throw new PlatformNotSupportedException( + "TlsSession is currently implemented only on Linux/FreeBSD (OpenSSL)."); + } + + return new TlsSession(context); + } + + // ── 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 => + _isHandshakeComplete ? (SslProtocols)_connectionInfo.Protocol : SslProtocols.None; + + [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 (_securityContext == null || _securityContext.IsInvalid) + { + return null; + } + return CertificateValidationPal.GetRemoteCertificate(_securityContext); + } + + // ── Handshake ───────────────────────────────────────────────────── + + public TlsOperationStatus ProcessHandshake( + ReadOnlySpan input, + Span output, + out int consumed, + out int produced) + { + ThrowIfDisposed(); + consumed = 0; + produced = 0; + + if (_isHandshakeComplete) + { + 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; + } + + ProtocolToken token = default; + token.RentBuffer = true; + try + { + if (_context.IsServer) + { + token = SslStreamPal.AcceptSecurityContext( + ref _credentialsHandle, + ref _securityContext, + input, + out consumed, + _context.Options); + } + else + { + string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost); + token = SslStreamPal.InitializeSecurityContext( + ref _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; + + if (done) + { + _isHandshakeComplete = true; + SslStreamPal.QueryContextConnectionInfo(_securityContext!, ref _connectionInfo); + } + + if (_pendingLength > 0) + { + produced = DrainTo(output); + if (_pendingLength > 0) + { + return TlsOperationStatus.WantWrite; + } + } + + if (done) + { + return TlsOperationStatus.Complete; + } + + // We made progress but still need more peer data. + return TlsOperationStatus.WantRead; + } + finally + { + token.ReleasePayload(); + } + } + + // ── Encrypt ─────────────────────────────────────────────────────── + + public TlsOperationStatus Encrypt( + ReadOnlySpan plaintext, + Span ciphertext, + out int consumed, + out int produced) + { + ThrowIfDisposed(); + 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, MaxRecordPlaintext); + byte[] rented = ArrayPool.Shared.Rent(chunk); + try + { + plaintext.Slice(0, chunk).CopyTo(rented); + + ProtocolToken token = SslStreamPal.EncryptMessage( + _securityContext!, + new ReadOnlyMemory(rented, 0, chunk), + 0, + 0); + + 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(); + 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: + consumed = frameSize; + return TlsOperationStatus.Closed; + + case SecurityStatusPalErrorCode.Renegotiate: + // Out of scope for the PoC. + throw new NotSupportedException( + "Renegotiation surfaced from Decrypt is not yet supported by TlsSession."); + + default: + throw new IOException(SR.net_io_decrypt, SslStreamPal.GetException(status)); + } + } + + // ── 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); + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + + _securityContext?.Dispose(); + _securityContext = null; + _credentialsHandle?.Dispose(); + _credentialsHandle = 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/tests/FunctionalTests/System.Net.Security.Tests.csproj b/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj index e8c82b958c5b6b..34864efb11183b 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..095761fcd0e09e --- /dev/null +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -0,0 +1,278 @@ +// 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.IO; +using System.Net.Test.Common; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +using TestCertificates = System.Net.Test.Common.Configuration.Certificates; + +namespace System.Net.Security.Tests +{ + [PlatformSpecific(TestPlatforms.Linux | TestPlatforms.FreeBSD)] + 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 = DriveServerHandshakeAsync(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_RejectsNullOptions() + { + Assert.Throws(() => TlsContext.Create((SslServerAuthenticationOptions)null!)); + 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 _)); + } + + // ── Helpers ──────────────────────────────────────────────────────── + + private static async Task DriveServerHandshakeAsync(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.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; + } + } + } + } +} From 183694ab98ff0d2b648c69363d5a8e91eb076652 Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 27 May 2026 03:10:41 +0000 Subject: [PATCH 02/18] Wire SslStream to TlsSession on Linux/FreeBSD Adds an internal wedge in SslStream that routes NextMessage / Encrypt / Decrypt through TlsSession on Linux/FreeBSD. On other platforms the partial method stubs return false and the existing PAL path runs unchanged. TlsSession additions: - TlsContext.WrapShared() so the wedge can reuse SslStream's own SslAuthenticationOptions bag (preserves SNI / cert selection results and avoids double Dispose). - HandshakeStepForSslStream / EncryptForSslStream / DecryptForSslStream surface that keeps SslStream's ProtocolToken-based plumbing intact. - SecurityContext / CredentialsHandle accessors so SslStream can mirror the SafeHandles back into its own fields for cert validation, channel binding, ProcessHandshakeSuccess, and Dispose to continue working. - TlsOperationStatus.WantCredentials surfaced from ProcessHandshake when the PAL reports CredentialsNeeded (OpenSSL client cert path). - Decrypt now treats Renegotiate as transparent: OpenSSL handles renegotiation internally inside SSL_read, so we just consume the frame and ask for more input. The wedge calls AcquireServerCredentials / AcquireClientCredentials on the very first server / client handshake step to preserve the legacy GenerateToken bootstrap (resolves the cert via the various delegates and assigns CertificateContext, which Interop.OpenSsl asserts on). Test status: 4969 of 4977 pass with wedge active; remaining 8 are TLS 1.3 post-handshake auth scenarios (mutual auth + client-cert callback returning null) that need follow-up. No new tests broken vs. baseline. --- .../ref/System.Net.Security.cs | 1 + .../src/System.Net.Security.csproj | 4 + .../System/Net/Security/SslStream.NotUnix.cs | 33 +++++ .../System/Net/Security/SslStream.Protocol.cs | 23 ++++ .../src/System/Net/Security/SslStream.Unix.cs | 124 ++++++++++++++++++ .../src/System/Net/Security/TlsContext.cs | 27 +++- .../System/Net/Security/TlsOperationStatus.cs | 7 + .../src/System/Net/Security/TlsSession.cs | 74 ++++++++++- 8 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 src/libraries/System.Net.Security/src/System/Net/Security/SslStream.NotUnix.cs create mode 100644 src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs 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 5bf7d44f5ce177..e37064cc023afb 100644 --- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs +++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs @@ -693,6 +693,7 @@ public enum TlsOperationStatus WantRead = 1, WantWrite = 2, Closed = 3, + WantCredentials = 4, } public sealed partial class TlsContext : System.IDisposable { 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 0d03e13e56dbce..2afce373b92782 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,10 @@ + + incomingBuffer, out ProtocolToken token, out int consumed) + { + token = default; + consumed = 0; + return false; + } + + private partial bool TryEncryptViaTlsSession(ReadOnlyMemory buffer, out ProtocolToken token) + { + token = default; + return false; + } + + private partial bool TryDecryptViaTlsSession(Span buffer, out SecurityStatusPal status, out int outputOffset, out int outputCount) + { + status = default; + outputOffset = 0; + outputCount = 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..3dede0073a7bea 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,10 @@ internal ProtocolToken NextMessage(ReadOnlySpan incomingBuffer, out int co return token; } + private partial bool TryNextMessageViaTlsSession(ReadOnlySpan incomingBuffer, out ProtocolToken token, out int consumed); + private partial bool TryEncryptViaTlsSession(ReadOnlyMemory buffer, out ProtocolToken token); + private partial bool TryDecryptViaTlsSession(Span buffer, out SecurityStatusPal status, out int outputOffset, out int outputCount); + /*++ GenerateToken - Called after each successive state in the Client - Server handshake. This function @@ -972,6 +985,11 @@ internal void ProcessHandshakeSuccess() internal ProtocolToken Encrypt(ReadOnlyMemory buffer) { + if (TryEncryptViaTlsSession(buffer, out ProtocolToken wedged)) + { + return wedged; + } + if (NetEventSource.Log.IsEnabled()) NetEventSource.DumpBuffer(this, buffer.Span); ProtocolToken token = SslStreamPal.EncryptMessage( @@ -990,6 +1008,11 @@ internal ProtocolToken Encrypt(ReadOnlyMemory buffer) internal SecurityStatusPal Decrypt(Span buffer, out int outputOffset, out int outputCount) { + if (TryDecryptViaTlsSession(buffer, out SecurityStatusPal wedgedStatus, out outputOffset, out outputCount)) + { + return wedgedStatus; + } + SecurityStatusPal status = SslStreamPal.DecryptMessage(_securityContext!, buffer, out outputOffset, out outputCount); if (NetEventSource.Log.IsEnabled() && status.ErrorCode == SecurityStatusPalErrorCode.OK) { diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs new file mode 100644 index 00000000000000..ca58acda9f902c --- /dev/null +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs @@ -0,0 +1,124 @@ +// 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 +{ + // Routes the SslStream handshake/encrypt/decrypt hot-path through TlsSession on + // Linux and FreeBSD. The PAL calls underneath are unchanged; this is a wedge + // that proves TlsSession is expressive enough to host SslStream's TLS engine. + // + // 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); + } + } + + 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. + if (_securityContext is null) + { + byte[]? thumbPrint = null; + if (_sslAuthenticationOptions!.IsServer) + { + AcquireServerCredentials(ref thumbPrint); + } + else + { + AcquireClientCredentials(ref thumbPrint); + } + } + + token = _tlsSession!.HandshakeStepForSslStream(incomingBuffer, out consumed); + + // OpenSSL surfaces CredentialsNeeded when the local cert callback returned + // null on the first call. Re-run client cert selection then drive the + // handshake again with no new input, matching the legacy GenerateToken loop. + if (token.Status.ErrorCode == SecurityStatusPalErrorCode.CredentialsNeeded) + { + if (NetEventSource.Log.IsEnabled()) + { + NetEventSource.Info(this, "TlsSession reported 'CredentialsNeeded'; reselecting client credentials."); + } + + byte[]? thumbPrint = null; + AcquireClientCredentials(ref thumbPrint, newCredentialsRequested: true); + + 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; + + return true; + } + + private partial bool TryEncryptViaTlsSession(ReadOnlyMemory buffer, out ProtocolToken token) + { + if (_tlsSession is null) + { + token = default; + return false; + } + + if (NetEventSource.Log.IsEnabled()) + { + NetEventSource.DumpBuffer(this, buffer.Span); + } + + token = _tlsSession.EncryptForSslStream(buffer, _headerSize, _trailerSize); + + if (token.Status.ErrorCode != SecurityStatusPalErrorCode.OK && NetEventSource.Log.IsEnabled()) + { + NetEventSource.Error(this, $"ERROR {token.Status}"); + } + + return true; + } + + private partial bool TryDecryptViaTlsSession(Span buffer, out SecurityStatusPal status, out int outputOffset, out int outputCount) + { + if (_tlsSession is null) + { + status = default; + outputOffset = 0; + outputCount = 0; + return false; + } + + status = _tlsSession.DecryptForSslStream(buffer, out outputOffset, out outputCount); + + if (NetEventSource.Log.IsEnabled() && status.ErrorCode == SecurityStatusPalErrorCode.OK) + { + NetEventSource.DumpBuffer(this, buffer.Slice(outputOffset, outputCount)); + } + + return true; + } + } +} 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 index bbf59c5ed9fc9a..0d470563a34041 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs @@ -1,6 +1,8 @@ // 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 { /// @@ -17,10 +19,12 @@ namespace System.Net.Security public sealed class TlsContext : IDisposable { private readonly SslAuthenticationOptions _options; + private readonly bool _ownsOptions; - private TlsContext(SslAuthenticationOptions options) + private TlsContext(SslAuthenticationOptions options, bool ownsOptions) { _options = options; + _ownsOptions = ownsOptions; } internal SslAuthenticationOptions Options => _options; @@ -32,7 +36,7 @@ public static TlsContext Create(SslServerAuthenticationOptions options) ArgumentNullException.ThrowIfNull(options); SslAuthenticationOptions bag = new SslAuthenticationOptions(); bag.UpdateOptions(options); - return new TlsContext(bag); + return new TlsContext(bag, ownsOptions: true); } public static TlsContext Create(SslClientAuthenticationOptions options) @@ -40,9 +44,24 @@ public static TlsContext Create(SslClientAuthenticationOptions options) ArgumentNullException.ThrowIfNull(options); SslAuthenticationOptions bag = new SslAuthenticationOptions(); bag.UpdateOptions(options); - return new TlsContext(bag); + return new TlsContext(bag, ownsOptions: true); + } + + // 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); } - public void Dispose() => _options.Dispose(); + public void Dispose() + { + if (_ownsOptions) + { + _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 index 0eb989e319571b..872861830b5029 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs @@ -26,5 +26,12 @@ public enum TlsOperationStatus /// 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, } } 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 index d9e9a67c6a29d9..910385fdfece7b 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -171,6 +171,7 @@ public TlsOperationStatus ProcessHandshake( } bool done = token.Status.ErrorCode == SecurityStatusPalErrorCode.OK; + bool needsCredentials = token.Status.ErrorCode == SecurityStatusPalErrorCode.CredentialsNeeded; if (done) { @@ -192,6 +193,11 @@ public TlsOperationStatus ProcessHandshake( return TlsOperationStatus.Complete; } + if (needsCredentials) + { + return TlsOperationStatus.WantCredentials; + } + // We made progress but still need more peer data. return TlsOperationStatus.WantRead; } @@ -339,9 +345,12 @@ public TlsOperationStatus Decrypt( return TlsOperationStatus.Closed; case SecurityStatusPalErrorCode.Renegotiate: - // Out of scope for the PoC. - throw new NotSupportedException( - "Renegotiation surfaced from Decrypt is not yet supported by TlsSession."); + // OpenSSL handles renegotiation transparently inside SSL_read/SSL_write. + // The PAL has already consumed the frame; surface no plaintext and ask + // the caller for more input. Any handshake bytes OpenSSL needs to send + // out will surface on the next Encrypt/Decrypt call. + consumed = frameSize; + return TlsOperationStatus.WantRead; default: throw new IOException(SR.net_io_decrypt, SslStreamPal.GetException(status)); @@ -431,6 +440,65 @@ private void EnsureDecryptScratch(int size) private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + // ── 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. + internal SafeDeleteSslContext? SecurityContext => _securityContext; + internal SafeFreeCredentials? CredentialsHandle => _credentialsHandle; + + // SslStream's GenerateToken replacement. Drives one ASC/ISC step via PAL and + // updates internal handshake-complete state. Returns the raw PAL token so the + // caller can preserve existing ProtocolToken-based plumbing (alerts, error + // mapping, NetEventSource). + internal ProtocolToken HandshakeStepForSslStream(ReadOnlySpan input, out int consumed) + { + ThrowIfDisposed(); + + ProtocolToken token; + if (_context.IsServer) + { + token = SslStreamPal.AcceptSecurityContext( + ref _credentialsHandle, + ref _securityContext, + input, + out consumed, + _context.Options); + } + else + { + string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost); + token = SslStreamPal.InitializeSecurityContext( + ref _credentialsHandle, + ref _securityContext, + hostName, + input, + out consumed, + _context.Options); + } + + if (token.Status.ErrorCode == SecurityStatusPalErrorCode.OK) + { + _isHandshakeComplete = true; + SslStreamPal.QueryContextConnectionInfo(_securityContext!, ref _connectionInfo); + } + + return token; + } + + internal ProtocolToken EncryptForSslStream(ReadOnlyMemory buffer, int headerSize, int trailerSize) + { + ThrowIfDisposed(); + return SslStreamPal.EncryptMessage(_securityContext!, buffer, headerSize, trailerSize); + } + + internal SecurityStatusPal DecryptForSslStream(Span buffer, out int offset, out int count) + { + ThrowIfDisposed(); + return SslStreamPal.DecryptMessage(_securityContext!, buffer, out offset, out count); + } + public void Dispose() { if (_disposed) From 6bc9d643260df0dca5be8b8feb78aaf186da94d0 Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 27 May 2026 04:11:18 +0000 Subject: [PATCH 03/18] TlsSession: add Shutdown API for TLS close_notify Adds public TlsOperationStatus Shutdown(Span, out int) to TlsSession on Linux/FreeBSD plus a PNSE stub on other platforms. Drives SslStreamPal.ApplyShutdownToken followed by one PAL handshake step to extract the close_notify bytes into pending output. Idempotent across drains; returns Closed once fully drained. Adds test ServerSession_Shutdown_DeliversCloseNotifyToSslStreamClient verifying an SslStream client observes EOF after the server-side shutdown. --- .../ref/System.Net.Security.cs | 1 + .../System/Net/Security/TlsSession.Stub.cs | 3 + .../src/System/Net/Security/TlsSession.cs | 79 +++++++++++++++++++ .../tests/FunctionalTests/TlsSessionTests.cs | 57 +++++++++++++ 4 files changed, 140 insertions(+) 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 e37064cc023afb..8de5050f6141c6 100644 --- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs +++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs @@ -718,6 +718,7 @@ internal TlsSession() { } 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 void Dispose() { } 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 index 8676c038ca6d04..9ea4cb0126caa7 100644 --- 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 @@ -42,6 +42,9 @@ public TlsOperationStatus Decrypt(ReadOnlySpan ciphertext, Span plai 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); 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 index 910385fdfece7b..a5916465b6ebea 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -357,6 +357,85 @@ public TlsOperationStatus Decrypt( } } + // ── 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 _credentialsHandle, + ref _securityContext, + ReadOnlySpan.Empty, + out _, + _context.Options); + } + else + { + string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost); + token = SslStreamPal.InitializeSecurityContext( + ref _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) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 095761fcd0e09e..bd76d238e9dd9b 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -96,6 +96,63 @@ public void TlsSession_OperationsBeforeHandshake_Throw() 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 = DriveServerHandshakeAsync(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); + } + } + // ── Helpers ──────────────────────────────────────────────────────── private static async Task DriveServerHandshakeAsync(TlsSession session, Stream transport) From c9acc8417ffacb5328773ce3d4f9209af60c6f0f Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 27 May 2026 04:15:35 +0000 Subject: [PATCH 04/18] TlsSession: add GetChannelBinding Adds public ChannelBinding? GetChannelBinding(ChannelBindingKind) to TlsSession on Linux/FreeBSD plus a PNSE stub on other platforms. Delegates to SslStreamPal.QueryContextChannelBinding so the binding material matches what SslStream produces over the same session. Adds test ServerSession_ChannelBinding_MatchesSslStreamClient that authenticates an SslStream client against a TlsSession server and verifies both sides derive the same tls-unique channel binding bytes. --- .../ref/System.Net.Security.cs | 1 + .../System/Net/Security/TlsSession.Stub.cs | 4 ++ .../src/System/Net/Security/TlsSession.cs | 17 +++++++ .../tests/FunctionalTests/TlsSessionTests.cs | 45 +++++++++++++++++++ 4 files changed, 67 insertions(+) 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 8de5050f6141c6..6e52ff8bc5122d 100644 --- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs +++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs @@ -721,6 +721,7 @@ internal TlsSession() { } 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.Authentication.ExtendedProtection.ChannelBinding? GetChannelBinding(System.Security.Authentication.ExtendedProtection.ChannelBindingKind kind) { throw null; } public void Dispose() { } } } 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 index 9ea4cb0126caa7..a91104cf99d95d 100644 --- 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 @@ -2,6 +2,7 @@ // 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 @@ -48,6 +49,9 @@ public TlsOperationStatus Shutdown(Span ciphertext, out int produced) => public X509Certificate2? GetRemoteCertificate() => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported); + public ChannelBinding? GetChannelBinding(ChannelBindingKind kind) => + 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 index a5916465b6ebea..76ba1e019ead07 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.IO; using System.Security.Authentication; +using System.Security.Authentication.ExtendedProtection; using System.Security.Cryptography.X509Certificates; namespace System.Net.Security @@ -109,6 +110,22 @@ public SslApplicationProtocol NegotiatedApplicationProtocol return CertificateValidationPal.GetRemoteCertificate(_securityContext); } + /// + /// 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( diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index bd76d238e9dd9b..067b4d15b8bec0 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -5,6 +5,7 @@ using System.IO; 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.Tasks; @@ -153,6 +154,50 @@ public async Task ServerSession_Shutdown_DeliversCloseNotifyToSslStreamClient() } } + [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 = DriveServerHandshakeAsync(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); + } + } + // ── Helpers ──────────────────────────────────────────────────────── private static async Task DriveServerHandshakeAsync(TlsSession session, Stream transport) From 783ebb3e6140f8e2882b8fbebf9b368862875a3c Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 27 May 2026 04:27:39 +0000 Subject: [PATCH 05/18] TlsSession: add LocalCertificate and RequestClientCertificate Adds two public APIs on TlsSession (Linux/FreeBSD) with PNSE stubs on other platforms: - X509Certificate2? LocalCertificate { get; } returns the server cert on a server session, or the negotiated client cert on a client session (using CertificateValidationPal.IsLocalCertificateUsed to gate the client-side case after handshake). - TlsOperationStatus RequestClientCertificate(Span, out int) drives SslStreamPal.Renegotiate which, on TLS 1.3, issues a post-handshake CertificateRequest, and on TLS 1.2 initiates renegotiation. The generated bytes flow through the pending-output buffer. After the caller forwards them and continues normal Decrypt, the peer's response is processed transparently by OpenSSL and the new certificate becomes observable via GetRemoteCertificate. The mutual-auth round trip is not yet end-to-end covered by a TlsSession test because TlsSession lacks plumbing for RemoteCertificateValidationCallback on the standalone path (Interop.OpenSsl.CertVerifyCallback derefs options.SslStream, which TlsSession does not own). That gap also affects any initial-handshake mutual-auth scenario on a standalone TlsSession and is tracked as a separate follow-up. --- .../ref/System.Net.Security.cs | 2 + .../System/Net/Security/TlsSession.Stub.cs | 6 ++ .../src/System/Net/Security/TlsSession.cs | 95 +++++++++++++++++++ 3 files changed, 103 insertions(+) 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 6e52ff8bc5122d..a646de49049a41 100644 --- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs +++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs @@ -721,7 +721,9 @@ internal TlsSession() { } 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.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() { } } } 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 index a91104cf99d95d..e1776f895c2cb2 100644 --- 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 @@ -49,9 +49,15 @@ public TlsOperationStatus Shutdown(Span ciphertext, out int produced) => public X509Certificate2? GetRemoteCertificate() => 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 index 76ba1e019ead07..63983c154e395f 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -110,6 +110,38 @@ public SslApplicationProtocol NegotiatedApplicationProtocol return CertificateValidationPal.GetRemoteCertificate(_securityContext); } + /// + /// 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(_credentialsHandle, _securityContext)) + { + return null; + } + + return _context.Options.CertificateContext?.TargetCertificate; + } + } + /// /// Returns a for the requested /// derived from the current TLS session, or @@ -374,6 +406,69 @@ public TlsOperationStatus Decrypt( } } + // ── Renegotiation / 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 _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; From a98d43e003efb7f853310d107b144d542d44c061 Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 27 May 2026 05:36:26 +0000 Subject: [PATCH 06/18] TlsSession: wire remote certificate validation hook Standalone TlsSession instances did not invoke the user-supplied RemoteCertificateValidationCallback because the OpenSSL CertVerifyCallback dereferenced options.SslStream, which is only set when an SslStream owns the session. Mutual-auth (including TLS 1.3 PHA) failed with 'certificate verify failed'. Decouple the verify callback from SslStream: - Add VerifyRemoteCertificateCallback delegate + RemoteCertificateValidator hook on SslAuthenticationOptions, plus a SafeSslHandle slot populated by SafeSslHandle.Create. - Extract SslStream.VerifyRemoteCertificate's core into a static helper (VerifyRemoteCertificateCore) shared by SslStream and TlsSession. - TlsSession.Create registers its own validator on the options when one isn't already set (SslStream wedge keeps its own). - Interop.OpenSsl.CertVerifyCallback now drives the hook + handle on options instead of options.SslStream. Add a server-side mutual-auth test that verifies the standalone TlsSession surfaces the client certificate to the user callback. --- .../Interop.OpenSsl.cs | 7 +- .../Interop.Ssl.cs | 4 + .../Net/Security/NetEventSource.Security.cs | 16 +-- .../Net/Security/SslAuthenticationOptions.cs | 19 ++++ .../System/Net/Security/SslStream.Protocol.cs | 97 ++++++++++++------- .../src/System/Net/Security/SslStream.cs | 1 + .../src/System/Net/Security/TlsSession.cs | 35 ++++++- .../tests/FunctionalTests/TlsSessionTests.cs | 50 ++++++++++ .../Fakes/FakeSslStream.Implementation.cs | 1 + 9 files changed, 186 insertions(+), 44 deletions(-) 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..0c066ab3e05aeb 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 @@ -874,7 +874,8 @@ 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"); // 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 +889,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..c3381960bfd12d 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 @@ -451,6 +451,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/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..6b0063f2a4836e 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,25 @@ internal void SetCertificateContextFromCert(X509Certificate2 certificate, bool? #if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL internal SslStream? SslStream { get; set; } + + // Set by SafeSslHandle.Create so OpenSSL's CertVerifyCallback can stash + // a CertificateValidationException on the handle when validation fails. + // Typed as the base SafeHandle so this file compiles in test projects + // that don't include the OpenSSL interop sources. + internal System.Runtime.InteropServices.SafeHandle? SafeSslHandle { get; set; } + + // Hook invoked by OpenSSL's CertVerifyCallback to drive remote + // certificate validation. Set by SslStream and by standalone TlsSession + // so both flows share the same callback plumbing. + internal delegate bool VerifyRemoteCertificateCallback( + X509Certificate2? certificate, + X509Chain? chain, + SslCertificateTrust? trust, + ref ProtocolToken alertToken, + out SslPolicyErrors sslPolicyErrors, + out X509ChainStatusFlags chainStatus); + + internal VerifyRemoteCertificateCallback? RemoteCertificateValidator { get; set; } #endif public void Dispose() diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs index 3dede0073a7bea..3a68231818736c 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 @@ -1084,17 +1084,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, @@ -1104,13 +1135,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; } @@ -1118,16 +1149,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; } @@ -1150,36 +1181,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; } @@ -1189,16 +1220,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) @@ -1328,22 +1359,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) @@ -1354,7 +1385,7 @@ private void LogCertificateValidation(RemoteCertificateValidationCallback? remot { chainStatusString += "\t" + chainStatus.StatusInformation; } - NetEventSource.Log.RemoteCertificateError(this, chainStatusString); + NetEventSource.Log.RemoteCertificateError(sender, chainStatusString); } } @@ -1362,18 +1393,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.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/TlsSession.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs index 63983c154e395f..c25841a69f83e0 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -49,6 +49,7 @@ public sealed class TlsSession : IDisposable private bool _isHandshakeComplete; private bool _disposed; private SslConnectionInfo _connectionInfo; + private X509Certificate2? _remoteCertificate; private TlsSession(TlsContext context) { @@ -65,7 +66,15 @@ public static TlsSession Create(TlsContext context) "TlsSession is currently implemented only on Linux/FreeBSD (OpenSSL)."); } - return new TlsSession(context); + TlsSession session = new TlsSession(context); + + // Provide a default cert validation hook so OpenSSL's CertVerifyCallback + // can drive the user RemoteCertificateValidationCallback even for a + // standalone TlsSession. If SslStream wraps this session (wedge mode), + // it sets its own validator first and we leave it untouched. + context.Options.RemoteCertificateValidator ??= session.VerifyRemoteCertificate; + + return session; } // ── State ───────────────────────────────────────────────────────── @@ -690,6 +699,30 @@ internal SecurityStatusPal DecryptForSslStream(Span buffer, out int offset return SslStreamPal.DecryptMessage(_securityContext!, buffer, out offset, out count); } + // Invoked by OpenSSL's CertVerifyCallback (via SslAuthenticationOptions.RemoteCertificateValidator) + // when this TlsSession owns the validation flow. + internal bool VerifyRemoteCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslCertificateTrust? trust, + ref ProtocolToken alertToken, + out SslPolicyErrors sslPolicyErrors, + out X509ChainStatusFlags chainStatus) + { + return SslStream.VerifyRemoteCertificateCore( + this, + _context.Options, + _securityContext, + ref _remoteCertificate, + ref _connectionInfo, + certificate, + chain, + trust, + ref alertToken, + out sslPolicyErrors, + out chainStatus); + } + public void Dispose() { if (_disposed) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 067b4d15b8bec0..9cb31e4822fb86 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -154,6 +154,56 @@ public async Task ServerSession_Shutdown_DeliversCloseNotifyToSslStreamClient() } } + [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 = DriveServerHandshakeAsync(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() { 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) { From 3ed7000e8ccf2a046b3f3d5dacd9672f48abb4fe Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 27 May 2026 17:44:12 +0000 Subject: [PATCH 07/18] TlsSession: drop Encrypt/Decrypt wedge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Encrypt/Decrypt wedge was a pure pass-through: both the wedged and direct paths called SslStreamPal.{Encrypt,Decrypt}Message on the same _securityContext (TlsSession mirrors that handle back to SslStream after each handshake step). There was no PAL difference to hide, just an extra hop, two partial methods, and the TlsSession-side helpers. Remove TryEncryptViaTlsSession / TryDecryptViaTlsSession (both partial declarations and Unix/NotUnix implementations), inline the PAL call in SslStream.Encrypt / SslStream.Decrypt, and drop the now-dead EncryptForSslStream / DecryptForSslStream helpers on TlsSession. The handshake wedge stays — that one owns credentials acquisition and the ProtocolToken plumbing, so it's a meaningful demonstration that TlsSession can host SslStream's TLS engine. --- .../System/Net/Security/SslStream.NotUnix.cs | 14 ------ .../System/Net/Security/SslStream.Protocol.cs | 12 ------ .../src/System/Net/Security/SslStream.Unix.cs | 43 ------------------- .../src/System/Net/Security/TlsSession.cs | 12 ------ 4 files changed, 81 deletions(-) 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 index f0d6e8854c6974..fe532408ebe4e6 100644 --- 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 @@ -14,20 +14,6 @@ private partial bool TryNextMessageViaTlsSession(ReadOnlySpan incomingBuff consumed = 0; return false; } - - private partial bool TryEncryptViaTlsSession(ReadOnlyMemory buffer, out ProtocolToken token) - { - token = default; - return false; - } - - private partial bool TryDecryptViaTlsSession(Span buffer, out SecurityStatusPal status, out int outputOffset, out int outputCount) - { - status = default; - outputOffset = 0; - outputCount = 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 3a68231818736c..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 @@ -808,8 +808,6 @@ internal ProtocolToken NextMessage(ReadOnlySpan incomingBuffer, out int co } private partial bool TryNextMessageViaTlsSession(ReadOnlySpan incomingBuffer, out ProtocolToken token, out int consumed); - private partial bool TryEncryptViaTlsSession(ReadOnlyMemory buffer, out ProtocolToken token); - private partial bool TryDecryptViaTlsSession(Span buffer, out SecurityStatusPal status, out int outputOffset, out int outputCount); /*++ GenerateToken - Called after each successive state @@ -985,11 +983,6 @@ internal void ProcessHandshakeSuccess() internal ProtocolToken Encrypt(ReadOnlyMemory buffer) { - if (TryEncryptViaTlsSession(buffer, out ProtocolToken wedged)) - { - return wedged; - } - if (NetEventSource.Log.IsEnabled()) NetEventSource.DumpBuffer(this, buffer.Span); ProtocolToken token = SslStreamPal.EncryptMessage( @@ -1008,11 +1001,6 @@ internal ProtocolToken Encrypt(ReadOnlyMemory buffer) internal SecurityStatusPal Decrypt(Span buffer, out int outputOffset, out int outputCount) { - if (TryDecryptViaTlsSession(buffer, out SecurityStatusPal wedgedStatus, out outputOffset, out outputCount)) - { - return wedgedStatus; - } - SecurityStatusPal status = SslStreamPal.DecryptMessage(_securityContext!, buffer, out outputOffset, out outputCount); if (NetEventSource.Log.IsEnabled() && status.ErrorCode == SecurityStatusPalErrorCode.OK) { diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs index ca58acda9f902c..4bf94206cb40f7 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs @@ -77,48 +77,5 @@ private partial bool TryNextMessageViaTlsSession(ReadOnlySpan incomingBuff return true; } - - private partial bool TryEncryptViaTlsSession(ReadOnlyMemory buffer, out ProtocolToken token) - { - if (_tlsSession is null) - { - token = default; - return false; - } - - if (NetEventSource.Log.IsEnabled()) - { - NetEventSource.DumpBuffer(this, buffer.Span); - } - - token = _tlsSession.EncryptForSslStream(buffer, _headerSize, _trailerSize); - - if (token.Status.ErrorCode != SecurityStatusPalErrorCode.OK && NetEventSource.Log.IsEnabled()) - { - NetEventSource.Error(this, $"ERROR {token.Status}"); - } - - return true; - } - - private partial bool TryDecryptViaTlsSession(Span buffer, out SecurityStatusPal status, out int outputOffset, out int outputCount) - { - if (_tlsSession is null) - { - status = default; - outputOffset = 0; - outputCount = 0; - return false; - } - - status = _tlsSession.DecryptForSslStream(buffer, out outputOffset, out outputCount); - - if (NetEventSource.Log.IsEnabled() && status.ErrorCode == SecurityStatusPalErrorCode.OK) - { - NetEventSource.DumpBuffer(this, buffer.Slice(outputOffset, outputCount)); - } - - return true; - } } } 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 index c25841a69f83e0..7866ca70ba9d37 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -687,18 +687,6 @@ internal ProtocolToken HandshakeStepForSslStream(ReadOnlySpan input, out i return token; } - internal ProtocolToken EncryptForSslStream(ReadOnlyMemory buffer, int headerSize, int trailerSize) - { - ThrowIfDisposed(); - return SslStreamPal.EncryptMessage(_securityContext!, buffer, headerSize, trailerSize); - } - - internal SecurityStatusPal DecryptForSslStream(Span buffer, out int offset, out int count) - { - ThrowIfDisposed(); - return SslStreamPal.DecryptMessage(_securityContext!, buffer, out offset, out count); - } - // Invoked by OpenSSL's CertVerifyCallback (via SslAuthenticationOptions.RemoteCertificateValidator) // when this TlsSession owns the validation flow. internal bool VerifyRemoteCertificate( From aaa71058e7c254453780e3a7136d5460c8cb6490 Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 27 May 2026 19:00:41 +0000 Subject: [PATCH 08/18] TlsSession: add standalone-handshake tests (in-memory + as-client) Two new tests: 1. TwoSessions_HandshakeAndPingPong_InMemory_Succeeds Pure in-memory TlsSession <-> TlsSession: no transport, no async at all. Drives both sides synchronously through ProcessHandshake by swapping byte arrays. Proves the TlsSession surface is a self-contained TLS engine that doesn't need SslStream or any I/O abstraction. Pinned to TLS 1.2: a pure in-memory two-session loop currently does not handle the TLS 1.3 post-handshake NewSessionTicket records OpenSSL emits after the server consumes the client Finished. With SslStream on one side those records are absorbed by SslStream's data path. The standalone TlsSession surface does not yet handle them (same scope as the PHA / renegotiation gap). 2. ClientSession_AgainstSslStreamServer_HandshakeAndPingPong_Succeeds Mirror of the existing server-side test: TlsSession is the client, SslStream is the server. Confirms the handshake driver is role- agnostic. Renamed DriveServerHandshakeAsync to DriveHandshakeAsync to reflect that it works for either role. --- .../tests/FunctionalTests/TlsSessionTests.cs | 186 +++++++++++++++++- 1 file changed, 181 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 9cb31e4822fb86..c732da100391d5 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -46,7 +46,7 @@ public async Task ServerSession_AgainstSslStreamClient_HandshakeAndPingPong_Succ RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate, }); - Task serverHandshake = DriveServerHandshakeAsync(session, serverStream); + Task serverHandshake = DriveHandshakeAsync(session, serverStream); await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30)); @@ -122,7 +122,7 @@ public async Task ServerSession_Shutdown_DeliversCloseNotifyToSslStreamClient() EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate, }); - Task serverHandshake = DriveServerHandshakeAsync(session, serverStream); + Task serverHandshake = DriveHandshakeAsync(session, serverStream); await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30)); byte[] scratch = ArrayPool.Shared.Rent(CipherBufSize); @@ -190,7 +190,7 @@ public async Task ServerSession_MutualAuth_InitialHandshake_InvokesValidator() ClientCertificates = new X509CertificateCollection { clientCert }, RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate, }); - Task serverHandshake = DriveServerHandshakeAsync(session, serverStream); + Task serverHandshake = DriveHandshakeAsync(session, serverStream); await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30)); Assert.True(session.IsHandshakeComplete); @@ -229,7 +229,7 @@ public async Task ServerSession_ChannelBinding_MatchesSslStreamClient() EnabledSslProtocols = SslProtocols.Tls12, RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate, }); - Task serverHandshake = DriveServerHandshakeAsync(session, serverStream); + Task serverHandshake = DriveHandshakeAsync(session, serverStream); await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30)); using ChannelBinding? serverBinding = session.GetChannelBinding(ChannelBindingKind.Unique); @@ -248,9 +248,185 @@ public async Task ServerSession_ChannelBinding_MatchesSslStreamClient() } } + // Pinned to TLS 1.2: a pure in-memory two-session loop currently can't + // handle the TLS 1.3 post-handshake NewSessionTicket records that OpenSSL + // emits after the server consumes the client Finished. With SslStream on + // one side the data-path handles those records transparently; the + // standalone TlsSession surface does not yet (same scope as PHA). + [Fact] + public void TwoSessions_HandshakeAndPingPong_InMemory_Succeeds() + { + using X509Certificate2 serverCert = TestCertificates.GetServerCertificate(); + string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false); + + using TlsContext serverCtx = TlsContext.Create(new SslServerAuthenticationOptions + { + ServerCertificate = serverCert, + EnabledSslProtocols = SslProtocols.Tls12, + ClientCertificateRequired = false, + }); + using TlsContext clientCtx = TlsContext.Create(new SslClientAuthenticationOptions + { + TargetHost = serverName, + EnabledSslProtocols = SslProtocols.Tls12, + 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(SslProtocols.Tls12, client.NegotiatedProtocol); + + 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)); + } + + [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); + } + } + // ── Helpers ──────────────────────────────────────────────────────── - private static async Task DriveServerHandshakeAsync(TlsSession session, Stream transport) + 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; + } + + 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(); + } + + private static async Task DriveHandshakeAsync(TlsSession session, Stream transport) { byte[] netIn = ArrayPool.Shared.Rent(CipherBufSize); byte[] netOut = ArrayPool.Shared.Rent(CipherBufSize); From aad66dd294b20fcf94ec51b6f52110d44daa8ab4 Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 27 May 2026 19:10:56 +0000 Subject: [PATCH 09/18] TlsSession: enable TLS 1.3 in-memory test + add non-blocking socket test Two changes: 1. Fix the in-memory two-session TLS 1.3 case. The previous 'bad MAC' failure was not a bug -- it was OpenSSL's normal TLS 1.3 state machine behavior surfacing in an unusual call pattern. After the server consumes the client Finished it emits NewSessionTicket records on the server->client side. The client MUST consume those records (via Decrypt) before its first Encrypt call: otherwise OpenSSL on the client has not yet finalized its write-key transition from client_handshake_traffic_secret to client_application_traffic_secret, and the server (which has already moved its receive key) rejects the resulting ciphertext as 'decryption failed or bad record mac'. In real network deployments the client's receive pump always consumes these bytes before sending; in this synchronous in-memory loop we drain them explicitly. Test is now a [Theory] covering both TLS 1.2 and TLS 1.3. 2. Add ServerSession_OnNonBlockingSocket_AgainstSslStreamClient_Succeeds. Drives a TlsSession-as-server against a real loopback Socket in non-blocking mode (Socket.Blocking = false). Sends and receives go through Socket.Send/Receive directly and handle WouldBlock by polling. The peer is a plain SslStream client over a NetworkStream. Exercises the 'give me raw socket bytes, I don't care about your I/O model' contract end to end: TlsSession itself never blocks on I/O; the caller is responsible for transport readiness. --- .../tests/FunctionalTests/TlsSessionTests.cs | 286 +++++++++++++++++- 1 file changed, 276 insertions(+), 10 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index c732da100391d5..1344714bf49250 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -3,11 +3,13 @@ using System.Buffers; 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; @@ -248,13 +250,23 @@ public async Task ServerSession_ChannelBinding_MatchesSslStreamClient() } } - // Pinned to TLS 1.2: a pure in-memory two-session loop currently can't - // handle the TLS 1.3 post-handshake NewSessionTicket records that OpenSSL - // emits after the server consumes the client Finished. With SslStream on - // one side the data-path handles those records transparently; the - // standalone TlsSession surface does not yet (same scope as PHA). - [Fact] - public void TwoSessions_HandshakeAndPingPong_InMemory_Succeeds() + // 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); @@ -262,13 +274,13 @@ public void TwoSessions_HandshakeAndPingPong_InMemory_Succeeds() using TlsContext serverCtx = TlsContext.Create(new SslServerAuthenticationOptions { ServerCertificate = serverCert, - EnabledSslProtocols = SslProtocols.Tls12, + EnabledSslProtocols = protocols, ClientCertificateRequired = false, }); using TlsContext clientCtx = TlsContext.Create(new SslClientAuthenticationOptions { TargetHost = serverName, - EnabledSslProtocols = SslProtocols.Tls12, + EnabledSslProtocols = protocols, RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate, }); @@ -287,7 +299,11 @@ public void TwoSessions_HandshakeAndPingPong_InMemory_Succeeds() Assert.True(client.IsHandshakeComplete); Assert.True(server.IsHandshakeComplete); Assert.Equal(client.NegotiatedProtocol, server.NegotiatedProtocol); - Assert.Equal(SslProtocols.Tls12, client.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(); @@ -296,6 +312,256 @@ public void TwoSessions_HandshakeAndPingPong_InMemory_Succeeds() 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() { From db70a0a434cb2ea134569abaa9c989d02005fcc4 Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 27 May 2026 19:45:41 +0000 Subject: [PATCH 10/18] TlsSession: Tier 1 Linux gaps (SNI cert selection, RequestRenegotiation, ALPN test, async-cert doc) --- .../ref/System.Net.Security.cs | 1 + .../src/System/Net/Security/TlsContext.cs | 10 ++ .../System/Net/Security/TlsSession.Stub.cs | 3 + .../src/System/Net/Security/TlsSession.cs | 88 ++++++++++++ .../tests/FunctionalTests/TlsSessionTests.cs | 130 ++++++++++++++++++ 5 files changed, 232 insertions(+) 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 a646de49049a41..9f188f70a88545 100644 --- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs +++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs @@ -724,6 +724,7 @@ internal TlsSession() { } 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 System.Net.Security.TlsOperationStatus RequestRenegotiation(System.Span ciphertext, out int produced) { throw null; } public void Dispose() { } } } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs index 0d470563a34041..5c95243b250f2f 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs @@ -39,6 +39,16 @@ public static TlsContext Create(SslServerAuthenticationOptions options) return new TlsContext(bag, ownsOptions: true); } + /// + /// Creates a client-side TLS context. + /// + /// + /// + /// must be a synchronous delegate. No async certificate validation + /// callback variant is supported by in the + /// current PoC; the callback runs inline on the thread that drives + /// . + /// public static TlsContext Create(SslClientAuthenticationOptions options) { ArgumentNullException.ThrowIfNull(options); 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 index e1776f895c2cb2..8b977ffd8d2ce1 100644 --- 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 @@ -58,6 +58,9 @@ public TlsOperationStatus Shutdown(Span ciphertext, out int produced) => public TlsOperationStatus RequestClientCertificate(Span ciphertext, out int produced) => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported); + public TlsOperationStatus RequestRenegotiation(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 index 7866ca70ba9d37..5df94fa0650a6b 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -197,6 +197,32 @@ public TlsOperationStatus ProcessHandshake( { 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) + { + bool needsCertResolution = + _context.Options.CertificateContext is null && + _context.Options.ServerCertSelectionDelegate is not null; + + if (input.Length == 0) + { + // Defer first PAL call until we have data: empty input would + // otherwise reach AllocateSslHandle and assert when cert + // resolution still owes a CertificateContext. + return TlsOperationStatus.WantRead; + } + + if (needsCertResolution && !ResolveServerCertificateFromClientHello(input)) + { + // Need more bytes to parse the ClientHello (and run the + // ServerCertificateSelectionCallback). + return TlsOperationStatus.WantRead; + } + } + token = SslStreamPal.AcceptSecurityContext( ref _credentialsHandle, ref _securityContext, @@ -417,6 +443,22 @@ public TlsOperationStatus Decrypt( // ── Renegotiation / Post-handshake auth ────────────────────────── + /// + /// Server-side: initiates a TLS renegotiation. On TLS 1.2 this issues + /// a HelloRequest; on TLS 1.3 this issues a post-handshake + /// CertificateRequest (same primitive as , + /// because OpenSSL exposes only the combined operation). + /// + /// + /// 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. + /// + public TlsOperationStatus RequestRenegotiation(Span ciphertext, out int produced) + => RequestClientCertificate(ciphertext, out produced); + /// /// Server-side: requests a client certificate from the peer after the /// initial handshake has completed. On TLS 1.3 this issues a @@ -640,6 +682,52 @@ private void EnsureDecryptScratch(int size) private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + // 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 diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 1344714bf49250..65560f88b47103 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -2,6 +2,7 @@ // 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; @@ -692,6 +693,135 @@ private static byte[] RoundtripRecord(TlsSession sender, TlsSession receiver, by 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_RequestRenegotiation_Tls12_ProducesHelloRequest() + { + 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); + + // Server requests renegotiation. We only verify that the API runs + // and produces a HelloRequest byte stream; driving the full + // renegotiation back through SslStream is intentionally out of + // scope here since SslStream's client-side reneg path needs the + // server to also pump the post-handshake read loop, which the + // standalone TlsSession leaves to the caller. + byte[] reneg = ArrayPool.Shared.Rent(CipherBufSize); + try + { + TlsOperationStatus status = session.RequestRenegotiation(reneg, out int produced); + Assert.NotEqual(TlsOperationStatus.Closed, status); + Assert.True(produced > 0, "RequestRenegotiation should emit a HelloRequest."); + } + finally + { + ArrayPool.Shared.Return(reneg); + } + } + } + private static async Task DriveHandshakeAsync(TlsSession session, Stream transport) { byte[] netIn = ArrayPool.Shared.Rent(CipherBufSize); From 45ab519ac5e9dc161d338c164eebd8ae299f35e9 Mon Sep 17 00:00:00 2001 From: Tomas Weinfurt Date: Thu, 28 May 2026 18:54:17 -0700 Subject: [PATCH 11/18] TlsSession: handle SChannel SEC_I_RENEGOTIATE post-handshake messages On TLS 1.3, SChannel surfaces SEC_I_RENEGOTIATE from DecryptMessage for post-handshake records (NewSessionTicket, KeyUpdate, post-handshake CertificateRequest). The decrypted inner record must be fed back into AcceptSecurityContext/InitializeSecurityContext or the next DecryptMessage returns SEC_E_CONTEXT_EXPIRED. Decrypt now invokes a new ProcessPostHandshakeMessage helper and returns Complete so the caller's loop re-enters to process any application data that arrived in the same TCP segment as the NST. All TlsSessionTests pass (13/13). --- .../src/System.Net.Security.csproj | 4 +- .../src/System/Net/Security/TlsSession.cs | 248 +++++++++++++++--- .../System.Net.Security.Tests.csproj | 2 +- .../tests/FunctionalTests/TlsSessionTests.cs | 2 +- 4 files changed, 218 insertions(+), 38 deletions(-) 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 2afce373b92782..ed274259f31da7 100644 --- a/src/libraries/System.Net.Security/src/System.Net.Security.csproj +++ b/src/libraries/System.Net.Security/src/System.Net.Security.csproj @@ -69,9 +69,9 @@ + Condition="'$(TargetPlatformIdentifier)' == 'linux' or '$(TargetPlatformIdentifier)' == 'freebsd' or '$(TargetPlatformIdentifier)' == 'windows'" /> + Condition="'$(TargetPlatformIdentifier)' != 'linux' and '$(TargetPlatformIdentifier)' != 'freebsd' and '$(TargetPlatformIdentifier)' != 'windows'" /> 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 index 5df94fa0650a6b..68e9fbd6edd205 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -12,9 +12,10 @@ namespace System.Net.Security { /// /// Non-blocking TLS state machine wrapper around the existing - /// . PoC scope: detached mode only (caller owns I/O), - /// Linux-only (OpenSSL). Provides , - /// , , and a pending-output queue. + /// . PoC scope: detached mode only (caller owns I/O). + /// Supported on Linux/FreeBSD (OpenSSL) and Windows (SChannel). Provides + /// , , , + /// and a pending-output queue. /// /// /// @@ -50,6 +51,9 @@ public sealed class TlsSession : IDisposable private bool _disposed; private SslConnectionInfo _connectionInfo; private X509Certificate2? _remoteCertificate; + private int _headerSize; + private int _trailerSize; + private int _maxDataSize = MaxRecordPlaintext; private TlsSession(TlsContext context) { @@ -60,19 +64,15 @@ public static TlsSession Create(TlsContext context) { ArgumentNullException.ThrowIfNull(context); - if (!OperatingSystem.IsLinux() && !OperatingSystem.IsFreeBSD()) - { - throw new PlatformNotSupportedException( - "TlsSession is currently implemented only on Linux/FreeBSD (OpenSSL)."); - } - TlsSession session = new TlsSession(context); +#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL // Provide a default cert validation hook so OpenSSL's CertVerifyCallback // can drive the user RemoteCertificateValidationCallback even for a // standalone TlsSession. If SslStream wraps this session (wedge mode), // it sets its own validator first and we leave it untouched. context.Options.RemoteCertificateValidator ??= session.VerifyRemoteCertificate; +#endif return session; } @@ -91,8 +91,34 @@ public string? TargetHostName set => _context.Options.TargetHost = value ?? string.Empty; } - public SslProtocols NegotiatedProtocol => - _isHandshakeComplete ? (SslProtocols)_connectionInfo.Protocol : SslProtocols.None; + 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 => @@ -191,6 +217,35 @@ public TlsOperationStatus ProcessHandshake( 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 @@ -207,14 +262,6 @@ public TlsOperationStatus ProcessHandshake( _context.Options.CertificateContext is null && _context.Options.ServerCertSelectionDelegate is not null; - if (input.Length == 0) - { - // Defer first PAL call until we have data: empty input would - // otherwise reach AllocateSslHandle and assert when cert - // resolution still owes a CertificateContext. - return TlsOperationStatus.WantRead; - } - if (needsCertResolution && !ResolveServerCertificateFromClientHello(input)) { // Need more bytes to parse the ClientHello (and run the @@ -223,6 +270,8 @@ _context.Options.CertificateContext is null && } } + EnsureCredentialsAcquired(); + token = SslStreamPal.AcceptSecurityContext( ref _credentialsHandle, ref _securityContext, @@ -232,6 +281,8 @@ _context.Options.CertificateContext is null && } else { + EnsureCredentialsAcquired(); + string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost); token = SslStreamPal.InitializeSecurityContext( ref _credentialsHandle, @@ -259,8 +310,7 @@ _context.Options.CertificateContext is null && if (done) { - _isHandshakeComplete = true; - SslStreamPal.QueryContextConnectionInfo(_securityContext!, ref _connectionInfo); + OnHandshakeCompleted(); } if (_pendingLength > 0) @@ -282,7 +332,18 @@ _context.Options.CertificateContext is null && return TlsOperationStatus.WantCredentials; } - // We made progress but still need more peer data. + // 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 @@ -291,8 +352,6 @@ _context.Options.CertificateContext is null && } } - // ── Encrypt ─────────────────────────────────────────────────────── - public TlsOperationStatus Encrypt( ReadOnlySpan plaintext, Span ciphertext, @@ -319,7 +378,7 @@ public TlsOperationStatus Encrypt( return TlsOperationStatus.Complete; } - int chunk = Math.Min(plaintext.Length, MaxRecordPlaintext); + int chunk = Math.Min(plaintext.Length, _maxDataSize); byte[] rented = ArrayPool.Shared.Rent(chunk); try { @@ -328,8 +387,8 @@ public TlsOperationStatus Encrypt( ProtocolToken token = SslStreamPal.EncryptMessage( _securityContext!, new ReadOnlyMemory(rented, 0, chunk), - 0, - 0); + _headerSize, + _trailerSize); try { @@ -425,16 +484,26 @@ public TlsOperationStatus Decrypt( return TlsOperationStatus.Complete; case SecurityStatusPalErrorCode.ContextExpired: + case SecurityStatusPalErrorCode.ContextExpiredError: consumed = frameSize; return TlsOperationStatus.Closed; case SecurityStatusPalErrorCode.Renegotiate: - // OpenSSL handles renegotiation transparently inside SSL_read/SSL_write. - // The PAL has already consumed the frame; surface no plaintext and ask - // the caller for more input. Any handshake bytes OpenSSL needs to send - // out will surface on the next Encrypt/Decrypt call. + // 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; - return TlsOperationStatus.WantRead; + 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)); @@ -768,13 +837,57 @@ internal ProtocolToken HandshakeStepForSslStream(ReadOnlySpan input, out i if (token.Status.ErrorCode == SecurityStatusPalErrorCode.OK) { - _isHandshakeComplete = true; - SslStreamPal.QueryContextConnectionInfo(_securityContext!, ref _connectionInfo); + 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 = !_context.IsServer || _context.Options.RemoteCertRequired; + if (needsValidation) + { + X509Chain? chain = null; + try + { + X509Certificate2? cert = CertificateValidationPal.GetRemoteCertificate( + _securityContext, ref chain, _context.Options.CertificateChainPolicy); + ProtocolToken alertToken = default; + SslStream.VerifyRemoteCertificateCore( + this, + _context.Options, + _securityContext, + ref _remoteCertificate, + ref _connectionInfo, + cert, + chain, + trust: null, + ref alertToken, + out _, + out _); + } + finally + { + chain?.Dispose(); + } + } + } + // Invoked by OpenSSL's CertVerifyCallback (via SslAuthenticationOptions.RemoteCertificateValidator) // when this TlsSession owns the validation flow. internal bool VerifyRemoteCertificate( @@ -799,6 +912,73 @@ internal bool VerifyRemoteCertificate( out chainStatus); } + // 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. + // + // PoC scope: minimal acquisition path — server requires a pre-set + // CertificateContext (or one resolved via ServerCertSelectionDelegate + // above), and the client connects anonymously. We don't yet integrate + // with SslSessionsCache, the legacy CertSelectionDelegate, or client + // certificate selection. + private void EnsureCredentialsAcquired() + { + if (_credentialsHandle is not null) + { + return; + } + + _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 _credentialsHandle, + ref _securityContext, + data, + out _, + _context.Options); + } + else + { + string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost); + token = SslStreamPal.InitializeSecurityContext( + ref _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) 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 34864efb11183b..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 @@ -37,7 +37,7 @@ + Condition="'$(TargetPlatformIdentifier)' == 'unix' or '$(TargetPlatformIdentifier)' == 'windows'" /> diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 65560f88b47103..b35527f9f4ad7d 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -18,7 +18,7 @@ namespace System.Net.Security.Tests { - [PlatformSpecific(TestPlatforms.Linux | TestPlatforms.FreeBSD)] + [PlatformSpecific(TestPlatforms.Linux | TestPlatforms.FreeBSD | TestPlatforms.Windows)] public class TlsSessionTests { private const int CipherBufSize = 32 * 1024; From 5ac0dbe9981bd75ec705f26130861802ac3febcb Mon Sep 17 00:00:00 2001 From: Tomas Weinfurt Date: Thu, 28 May 2026 23:28:46 -0700 Subject: [PATCH 12/18] TlsSession wedge: refresh credential cache after CredentialsNeeded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror legacy GenerateToken's bookkeeping: when AcquireClientCredentials is re-run with newCredentialsRequested=true (in response to SChannel's CredentialsNeeded after parsing the server's CertificateRequest), set refreshCredentialNeeded so the finally-block publishes the new cert-bound credential to SslSessionsCache. Without this, subsequent connections always missed the cache (anonymous cred cached, cert-bound cred discarded) and the SChannel session ticket never resumed. Also routes credential ownership through TlsContext so the wedge and standalone TlsContext.Create paths share lifetime semantics, and removes diagnostic file-logging used during root-cause analysis. Validated: System.Net.Security.Tests full suite — Total 5247, Failed 0, Skipped 40. --- .../src/System.Net.Security.csproj | 6 +- .../System/Net/Security/SslSessionsCache.cs | 5 +- .../Net/Security/SslStream.TlsSessionWedge.cs | 153 ++++++++++++++++++ .../src/System/Net/Security/SslStream.Unix.cs | 81 ---------- .../src/System/Net/Security/TlsContext.cs | 8 + .../src/System/Net/Security/TlsSession.cs | 48 ++++-- 6 files changed, 197 insertions(+), 104 deletions(-) create mode 100644 src/libraries/System.Net.Security/src/System/Net/Security/SslStream.TlsSessionWedge.cs delete mode 100644 src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs 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 ed274259f31da7..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,10 +62,10 @@ - + + Condition="'$(TargetPlatformIdentifier)' != 'linux' and '$(TargetPlatformIdentifier)' != 'freebsd' and '$(TargetPlatformIdentifier)' != 'windows'" /> 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.Unix.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs deleted file mode 100644 index 4bf94206cb40f7..00000000000000 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Unix.cs +++ /dev/null @@ -1,81 +0,0 @@ -// 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 -{ - // Routes the SslStream handshake/encrypt/decrypt hot-path through TlsSession on - // Linux and FreeBSD. The PAL calls underneath are unchanged; this is a wedge - // that proves TlsSession is expressive enough to host SslStream's TLS engine. - // - // 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); - } - } - - 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. - if (_securityContext is null) - { - byte[]? thumbPrint = null; - if (_sslAuthenticationOptions!.IsServer) - { - AcquireServerCredentials(ref thumbPrint); - } - else - { - AcquireClientCredentials(ref thumbPrint); - } - } - - token = _tlsSession!.HandshakeStepForSslStream(incomingBuffer, out consumed); - - // OpenSSL surfaces CredentialsNeeded when the local cert callback returned - // null on the first call. Re-run client cert selection then drive the - // handshake again with no new input, matching the legacy GenerateToken loop. - if (token.Status.ErrorCode == SecurityStatusPalErrorCode.CredentialsNeeded) - { - if (NetEventSource.Log.IsEnabled()) - { - NetEventSource.Info(this, "TlsSession reported 'CredentialsNeeded'; reselecting client credentials."); - } - - byte[]? thumbPrint = null; - AcquireClientCredentials(ref thumbPrint, newCredentialsRequested: true); - - 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; - - return true; - } - } -} 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 index 5c95243b250f2f..021ef97d81020c 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs @@ -21,6 +21,12 @@ public sealed class TlsContext : IDisposable private readonly SslAuthenticationOptions _options; private readonly bool _ownsOptions; + // 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) { _options = options; @@ -70,6 +76,8 @@ public void Dispose() { if (_ownsOptions) { + CredentialsHandle?.Dispose(); + CredentialsHandle = null; _options.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 index 68e9fbd6edd205..9753afea59db6c 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -38,7 +38,6 @@ public sealed class TlsSession : IDisposable internal const int MaxRecordPlaintext = 16354; private readonly TlsContext _context; - private SafeFreeCredentials? _credentialsHandle; private SafeDeleteSslContext? _securityContext; private byte[]? _pending; @@ -48,6 +47,7 @@ public sealed class TlsSession : IDisposable private byte[]? _decryptScratch; private bool _isHandshakeComplete; + private bool _suppressInternalCertificateValidation; private bool _disposed; private SslConnectionInfo _connectionInfo; private X509Certificate2? _remoteCertificate; @@ -168,7 +168,7 @@ public X509Certificate2? LocalCertificate return null; } - if (!CertificateValidationPal.IsLocalCertificateUsed(_credentialsHandle, _securityContext)) + if (!CertificateValidationPal.IsLocalCertificateUsed(_context.CredentialsHandle, _securityContext)) { return null; } @@ -273,7 +273,7 @@ _context.Options.CertificateContext is null && EnsureCredentialsAcquired(); token = SslStreamPal.AcceptSecurityContext( - ref _credentialsHandle, + ref _context.CredentialsHandle, ref _securityContext, input, out consumed, @@ -285,7 +285,7 @@ _context.Options.CertificateContext is null && string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost); token = SslStreamPal.InitializeSecurityContext( - ref _credentialsHandle, + ref _context.CredentialsHandle, ref _securityContext, hostName, input, @@ -563,7 +563,7 @@ public TlsOperationStatus RequestClientCertificate(Span ciphertext, out in if (_pendingLength == 0) { ProtocolToken token = SslStreamPal.Renegotiate( - ref _credentialsHandle, + ref _context.CredentialsHandle, ref _securityContext!, _context.Options); try @@ -634,7 +634,7 @@ public TlsOperationStatus Shutdown(Span ciphertext, out int produced) if (_context.IsServer) { token = SslStreamPal.AcceptSecurityContext( - ref _credentialsHandle, + ref _context.CredentialsHandle, ref _securityContext, ReadOnlySpan.Empty, out _, @@ -644,7 +644,7 @@ public TlsOperationStatus Shutdown(Span ciphertext, out int produced) { string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost); token = SslStreamPal.InitializeSecurityContext( - ref _credentialsHandle, + ref _context.CredentialsHandle, ref _securityContext, hostName, ReadOnlySpan.Empty, @@ -802,8 +802,23 @@ private bool ResolveServerCertificateFromClientHello(ReadOnlySpan input) // 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 SafeFreeCredentials? CredentialsHandle => _credentialsHandle; + 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 @@ -817,7 +832,7 @@ internal ProtocolToken HandshakeStepForSslStream(ReadOnlySpan input, out i if (_context.IsServer) { token = SslStreamPal.AcceptSecurityContext( - ref _credentialsHandle, + ref _context.CredentialsHandle, ref _securityContext, input, out consumed, @@ -827,7 +842,7 @@ internal ProtocolToken HandshakeStepForSslStream(ReadOnlySpan input, out i { string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost); token = SslStreamPal.InitializeSecurityContext( - ref _credentialsHandle, + ref _context.CredentialsHandle, ref _securityContext, hostName, input, @@ -859,7 +874,8 @@ private void OnHandshakeCompleted() // 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 = !_context.IsServer || _context.Options.RemoteCertRequired; + bool needsValidation = !_suppressInternalCertificateValidation && + (!_context.IsServer || _context.Options.RemoteCertRequired); if (needsValidation) { X509Chain? chain = null; @@ -923,12 +939,12 @@ internal bool VerifyRemoteCertificate( // certificate selection. private void EnsureCredentialsAcquired() { - if (_credentialsHandle is not null) + if (_context.CredentialsHandle is not null) { return; } - _credentialsHandle = SslStreamPal.AcquireCredentialsHandle(_context.Options, false); + _context.CredentialsHandle = SslStreamPal.AcquireCredentialsHandle(_context.Options, false); } // Feed a decrypted post-handshake message (e.g. TLS 1.3 NewSessionTicket @@ -949,7 +965,7 @@ private void ProcessPostHandshakeMessage(ReadOnlySpan data) if (_context.IsServer) { token = SslStreamPal.AcceptSecurityContext( - ref _credentialsHandle, + ref _context.CredentialsHandle, ref _securityContext, data, out _, @@ -959,7 +975,7 @@ private void ProcessPostHandshakeMessage(ReadOnlySpan data) { string hostName = TargetHostNameHelper.NormalizeHostName(_context.Options.TargetHost); token = SslStreamPal.InitializeSecurityContext( - ref _credentialsHandle, + ref _context.CredentialsHandle, ref _securityContext, hostName, data, @@ -989,8 +1005,6 @@ public void Dispose() _securityContext?.Dispose(); _securityContext = null; - _credentialsHandle?.Dispose(); - _credentialsHandle = null; if (_pending != null) { From 9599ca81a8426d9976c43ff9382fe8fcba9b7b16 Mon Sep 17 00:00:00 2001 From: wfurt Date: Fri, 29 May 2026 20:25:41 +0000 Subject: [PATCH 13/18] checkpoint --- TlsSession-proposal.md | 757 ++++++++++++++++++ .../ref/System.Net.Security.cs | 5 + .../src/System/Net/Security/TlsContext.cs | 32 +- .../System/Net/Security/TlsOperationStatus.cs | 15 + .../System/Net/Security/TlsSession.Stub.cs | 9 + .../src/System/Net/Security/TlsSession.cs | 199 ++++- 6 files changed, 1005 insertions(+), 12 deletions(-) create mode 100644 TlsSession-proposal.md 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/System.Net.Security/ref/System.Net.Security.cs b/src/libraries/System.Net.Security/ref/System.Net.Security.cs index 9f188f70a88545..0cb656d18ac4d1 100644 --- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs +++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs @@ -694,11 +694,13 @@ public enum TlsOperationStatus WantWrite = 2, Closed = 3, WantCredentials = 4, + NeedsCertificateValidation = 5, } public sealed partial class TlsContext : System.IDisposable { internal TlsContext() { } public bool IsServer { get { throw null; } } + public bool UseExternalCertificateValidation { get { throw null; } set { } } 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() { } @@ -721,6 +723,9 @@ internal TlsSession() { } 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.X509Chain? GetRemoteCertificateChain() { throw null; } + public System.Net.Security.SslPolicyErrors AcceptWithDefaultValidation() { throw null; } + public void SetRemoteCertificateValidationResult(System.Net.Security.SslPolicyErrors errors) { } 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; } 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 index 021ef97d81020c..bc33fcac6994d9 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs @@ -37,6 +37,27 @@ private TlsContext(SslAuthenticationOptions options, bool ownsOptions) public bool IsServer => _options.IsServer; + /// + /// When true, hands the peer certificate back to + /// the caller after the handshake completes (via + /// ) instead of running + /// validation inside the TLS state machine. The caller is responsible for + /// validating the peer certificate — which may involve I/O such as AIA fetching + /// or CRL/OCSP lookups — and reporting the result back via + /// + /// (or ). + /// + /// + /// Default is false, in which case validation runs inline (preserving + /// existing -compatible behavior). When enabled, any + /// set + /// on the underlying options is ignored — the caller drives validation entirely. + /// On OpenSSL, the peer briefly sees the handshake complete before any rejection + /// alert is sent; this is a deliberate trade-off to keep validation outside the + /// TLS state machine. + /// + public bool UseExternalCertificateValidation { get; set; } + public static TlsContext Create(SslServerAuthenticationOptions options) { ArgumentNullException.ThrowIfNull(options); @@ -49,11 +70,12 @@ public static TlsContext Create(SslServerAuthenticationOptions options) /// Creates a client-side TLS context. /// /// - /// - /// must be a synchronous delegate. No async certificate validation - /// callback variant is supported by in the - /// current PoC; the callback runs inline on the thread that drives - /// . + /// By default, + /// runs inline on the thread that drives + /// — no async callback variant is supported. Callers that need to perform + /// expensive validation (AIA fetch, CRL/OCSP lookup) outside the TLS state + /// machine should set and drive + /// validation in response to . /// public static TlsContext Create(SslClientAuthenticationOptions options) { 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 index 872861830b5029..bea631abec310f 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs @@ -33,5 +33,20 @@ public enum TlsOperationStatus /// and call again with empty input. /// WantCredentials = 4, + + /// + /// The peer presented a certificate and the caller has opted in to external + /// certificate validation (). + /// The caller should retrieve the peer certificate via + /// (and optionally the built chain + /// via ), perform validation — + /// including any I/O such as AIA fetch or CRL/OCSP lookup — on any thread, and + /// then record the result with + /// + /// or invoke for + /// -equivalent default validation. Until the result is + /// set, and throw. + /// + NeedsCertificateValidation = 5, } } 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 index 8b977ffd8d2ce1..a82e120577ba23 100644 --- 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 @@ -49,6 +49,15 @@ public TlsOperationStatus Shutdown(Span ciphertext, out int produced) => public X509Certificate2? GetRemoteCertificate() => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported); + public X509Chain? GetRemoteCertificateChain() => + 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 X509Certificate2? LocalCertificate => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported); diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs index 9753afea59db6c..127984aa4450a2 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -48,6 +48,10 @@ public sealed class TlsSession : IDisposable private bool _isHandshakeComplete; private bool _suppressInternalCertificateValidation; + private bool _useExternalValidation; + private bool _externalValidationPending; + private X509Chain? _externalValidationChain; + private Exception? _externalValidationFault; private bool _disposed; private SslConnectionInfo _connectionInfo; private X509Certificate2? _remoteCertificate; @@ -65,18 +69,45 @@ public static TlsSession Create(TlsContext context) ArgumentNullException.ThrowIfNull(context); TlsSession session = new TlsSession(context); + session._useExternalValidation = context.UseExternalCertificateValidation; #if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL - // Provide a default cert validation hook so OpenSSL's CertVerifyCallback - // can drive the user RemoteCertificateValidationCallback even for a - // standalone TlsSession. If SslStream wraps this session (wedge mode), - // it sets its own validator first and we leave it untouched. - context.Options.RemoteCertificateValidator ??= session.VerifyRemoteCertificate; + if (session._useExternalValidation) + { + // OpenSSL's CertVerifyCallback must answer synchronously. In external- + // validation mode we accept the peer cert unconditionally inside the + // callback and surface NeedsCertificateValidation after the handshake + // completes, so real validation happens outside the state machine. + context.Options.RemoteCertificateValidator = AcceptAllForExternalValidation; + } + else + { + // Provide a default cert validation hook so OpenSSL's CertVerifyCallback + // can drive the user RemoteCertificateValidationCallback even for a + // standalone TlsSession. If SslStream wraps this session (wedge mode), + // it sets its own validator first and we leave it untouched. + context.Options.RemoteCertificateValidator ??= session.VerifyRemoteCertificate; + } #endif return session; } +#pragma warning disable IDE0060 // signature is fixed by SslAuthenticationOptions.VerifyRemoteCertificateCallback + private static bool AcceptAllForExternalValidation( + X509Certificate2? certificate, + X509Chain? chain, + SslCertificateTrust? trust, + ref ProtocolToken alertToken, + out SslPolicyErrors sslPolicyErrors, + out X509ChainStatusFlags chainStatus) + { + sslPolicyErrors = SslPolicyErrors.None; + chainStatus = X509ChainStatusFlags.NoError; + return true; + } +#pragma warning restore IDE0060 + // ── State ───────────────────────────────────────────────────────── public bool IsServer => _context.IsServer; @@ -138,6 +169,11 @@ public SslApplicationProtocol NegotiatedApplicationProtocol public X509Certificate2? GetRemoteCertificate() { + if (_remoteCertificate is not null) + { + return _remoteCertificate; + } + if (_securityContext == null || _securityContext.IsInvalid) { return null; @@ -145,6 +181,121 @@ public SslApplicationProtocol NegotiatedApplicationProtocol return CertificateValidationPal.GetRemoteCertificate(_securityContext); } + /// + /// Returns the the platform built for the peer + /// certificate during handshake, or null if no chain was retained. + /// Only meaningful when + /// is true + /// and the session is awaiting an external validation result. The chain is + /// owned by the session and disposed when the session is disposed or when + /// the validation result is recorded. + /// + public X509Chain? GetRemoteCertificateChain() + { + ThrowIfDisposed(); + return _externalValidationChain; + } + + /// + /// 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."); + } + + ProtocolToken alertToken = default; + bool ok = SslStream.VerifyRemoteCertificateCore( + this, + _context.Options, + _securityContext, + ref _remoteCertificate, + ref _connectionInfo, + _remoteCertificate, + _externalValidationChain, + trust: null, + ref alertToken, + out SslPolicyErrors sslPolicyErrors, + out _); + + 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; + + if (errors != SslPolicyErrors.None) + { + _externalValidationFault = new AuthenticationException(SR.net_ssl_io_cert_validation); + } + + DisposeExternalValidationChain(); + } + + 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 DisposeExternalValidationChain() + { + X509Chain? chain = _externalValidationChain; + _externalValidationChain = null; + if (chain is null) + { + return; + } + + // Dispose elements we own (mirrors the cleanup the OpenSSL CertVerifyCallback + // does when no user CertValidationDelegate is registered). + int elementsCount = chain.ChainElements.Count; + for (int i = 0; i < elementsCount; i++) + { + chain.ChainElements[i].Certificate.Dispose(); + } + chain.Dispose(); + } + /// /// Returns the local certificate sent to the peer, or null if no /// local certificate was negotiated. For a server session this is the @@ -205,6 +356,16 @@ public TlsOperationStatus ProcessHandshake( consumed = 0; produced = 0; + if (_externalValidationFault is not null) + { + throw _externalValidationFault; + } + + if (_externalValidationPending) + { + return TlsOperationStatus.NeedsCertificateValidation; + } + if (_isHandshakeComplete) { throw new InvalidOperationException("Handshake has already completed."); @@ -324,7 +485,9 @@ _context.Options.CertificateContext is null && if (done) { - return TlsOperationStatus.Complete; + return _externalValidationPending + ? TlsOperationStatus.NeedsCertificateValidation + : TlsOperationStatus.Complete; } if (needsCredentials) @@ -359,6 +522,7 @@ public TlsOperationStatus Encrypt( out int produced) { ThrowIfDisposed(); + ThrowIfPendingExternalValidation(); consumed = 0; produced = 0; @@ -428,6 +592,7 @@ public TlsOperationStatus Decrypt( out int produced) { ThrowIfDisposed(); + ThrowIfPendingExternalValidation(); consumed = 0; produced = 0; @@ -876,7 +1041,25 @@ private void OnHandshakeCompleted() // ClientCertificateRequired). bool needsValidation = !_suppressInternalCertificateValidation && (!_context.IsServer || _context.Options.RemoteCertRequired); - if (needsValidation) + if (!needsValidation) + { + return; + } + + if (_useExternalValidation) + { + // Hand the peer cert + chain back to the caller via + // NeedsCertificateValidation. We build the chain here once and retain it + // until the caller records a result (or the session is disposed) so the + // caller can inspect it on any thread without re-querying the PAL. + X509Chain? chain = null; + _remoteCertificate = CertificateValidationPal.GetRemoteCertificate( + _securityContext, ref chain, _context.Options.CertificateChainPolicy); + _externalValidationChain = chain; + _externalValidationPending = true; + return; + } + { X509Chain? chain = null; try @@ -1003,6 +1186,8 @@ public void Dispose() } _disposed = true; + DisposeExternalValidationChain(); + _securityContext?.Dispose(); _securityContext = null; From b821de944f321398f9d14cf7b3f2fdcb77d65041 Mon Sep 17 00:00:00 2001 From: wfurt Date: Fri, 29 May 2026 15:04:02 -0700 Subject: [PATCH 14/18] TlsSession: defer cert validation via SSL_set_retry_verify on OpenSSL 3.0+ Wires up the OpenSSL 3.0 SSL_set_retry_verify lightup so SslStream-style external certificate validation can suspend the TLS handshake mid-stream on 3.0+ and resume after the application accepts/rejects the peer cert. Falls back to the existing post-handshake suspension on 1.1.x. Native: - Add CryptoNative_SslSetRetryVerify export (LIGHTUP_FUNCTION pattern). - Add PAL_SSL_ERROR_WANT_RETRY_VERIFY (= 12) and forward-declare SSL_set_retry_verify in osslcompat_30.h; ifndef-guard the error code for 1.1.x headers. Managed: - Add SslErrorCode.SSL_ERROR_WANT_RETRY_VERIFY and matching P/Invoke. - DoSslHandshake maps the new error to NeedsRemoteCertificateValidation. - CertVerifyCallback calls SslSetRetryVerify and returns -1 to suspend when DeferCertificateValidation is set and no managed validator is provided; otherwise falls through to the legacy accept-and-validate path. - Add internal SslAuthenticationOptions.DeferCertificateValidation (OpenSSL only). - TlsSession enables DeferCertificateValidation, drops the dummy AcceptAllForExternalValidation callback, and tracks _externalValidationResolved so the second ProcessHandshake call after validation succeeds returns Complete rather than throwing. Tests: System.Net.Security functional suite 4965 / 0 fail / 19 skip on macOS arm64. --- .../Interop.OpenSsl.cs | 26 +++ .../Interop.Ssl.cs | 4 + .../ref/System.Net.Security.cs | 1 - .../Net/Security/SslAuthenticationOptions.cs | 6 + .../src/System/Net/Security/TlsContext.cs | 34 +-- .../System/Net/Security/TlsOperationStatus.cs | 14 +- .../src/System/Net/Security/TlsSession.cs | 199 +++++++--------- .../src/System/Net/SecurityStatusPal.cs | 1 + .../tests/FunctionalTests/TlsSessionTests.cs | 221 ++++++++++++++++++ .../entrypoints.c | 1 + .../opensslshim.h | 2 + .../osslcompat_30.h | 2 + .../pal_ssl.c | 18 ++ .../pal_ssl.h | 7 + 14 files changed, 392 insertions(+), 144 deletions(-) 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 0c066ab3e05aeb..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 @@ -877,6 +886,23 @@ internal static int CertVerifyCallback(IntPtr storeCtx, IntPtr arg) 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 // chain and we want to dispose only these after we perform the 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 c3381960bfd12d..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. 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 0cb656d18ac4d1..3c6265da95bc39 100644 --- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs +++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs @@ -700,7 +700,6 @@ public sealed partial class TlsContext : System.IDisposable { internal TlsContext() { } public bool IsServer { get { throw null; } } - public bool UseExternalCertificateValidation { get { throw null; } set { } } 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() { } 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 6b0063f2a4836e..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 @@ -244,6 +244,12 @@ internal delegate bool VerifyRemoteCertificateCallback( 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/TlsContext.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs index bc33fcac6994d9..f36c9ae7537b4a 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs @@ -37,27 +37,6 @@ private TlsContext(SslAuthenticationOptions options, bool ownsOptions) public bool IsServer => _options.IsServer; - /// - /// When true, hands the peer certificate back to - /// the caller after the handshake completes (via - /// ) instead of running - /// validation inside the TLS state machine. The caller is responsible for - /// validating the peer certificate — which may involve I/O such as AIA fetching - /// or CRL/OCSP lookups — and reporting the result back via - /// - /// (or ). - /// - /// - /// Default is false, in which case validation runs inline (preserving - /// existing -compatible behavior). When enabled, any - /// set - /// on the underlying options is ignored — the caller drives validation entirely. - /// On OpenSSL, the peer briefly sees the handshake complete before any rejection - /// alert is sent; this is a deliberate trade-off to keep validation outside the - /// TLS state machine. - /// - public bool UseExternalCertificateValidation { get; set; } - public static TlsContext Create(SslServerAuthenticationOptions options) { ArgumentNullException.ThrowIfNull(options); @@ -70,12 +49,13 @@ public static TlsContext Create(SslServerAuthenticationOptions options) /// Creates a client-side TLS context. /// /// - /// By default, - /// runs inline on the thread that drives - /// — no async callback variant is supported. Callers that need to perform - /// expensive validation (AIA fetch, CRL/OCSP lookup) outside the TLS state - /// machine should set and drive - /// validation in response to . + /// 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) { 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 index bea631abec310f..6f271a33e49fbf 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs @@ -35,17 +35,17 @@ public enum TlsOperationStatus WantCredentials = 4, /// - /// The peer presented a certificate and the caller has opted in to external - /// certificate validation (). - /// The caller should retrieve the peer certificate via + /// 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 optionally the built chain /// via ), perform validation — /// including any I/O such as AIA fetch or CRL/OCSP lookup — on any thread, and /// then record the result with - /// - /// or invoke for - /// -equivalent default validation. Until the result is - /// set, and throw. + /// . + /// 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, } 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 index 127984aa4450a2..2ac2efae850cea 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -48,9 +48,10 @@ public sealed class TlsSession : IDisposable private bool _isHandshakeComplete; private bool _suppressInternalCertificateValidation; - private bool _useExternalValidation; private bool _externalValidationPending; + private bool _externalValidationResolved; private X509Chain? _externalValidationChain; + private X509Certificate2? _externalPendingCert; private Exception? _externalValidationFault; private bool _disposed; private SslConnectionInfo _connectionInfo; @@ -69,45 +70,21 @@ public static TlsSession Create(TlsContext context) ArgumentNullException.ThrowIfNull(context); TlsSession session = new TlsSession(context); - session._useExternalValidation = context.UseExternalCertificateValidation; #if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL - if (session._useExternalValidation) - { - // OpenSSL's CertVerifyCallback must answer synchronously. In external- - // validation mode we accept the peer cert unconditionally inside the - // callback and surface NeedsCertificateValidation after the handshake - // completes, so real validation happens outside the state machine. - context.Options.RemoteCertificateValidator = AcceptAllForExternalValidation; - } - else - { - // Provide a default cert validation hook so OpenSSL's CertVerifyCallback - // can drive the user RemoteCertificateValidationCallback even for a - // standalone TlsSession. If SslStream wraps this session (wedge mode), - // it sets its own validator first and we leave it untouched. - context.Options.RemoteCertificateValidator ??= session.VerifyRemoteCertificate; - } + // 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; } -#pragma warning disable IDE0060 // signature is fixed by SslAuthenticationOptions.VerifyRemoteCertificateCallback - private static bool AcceptAllForExternalValidation( - X509Certificate2? certificate, - X509Chain? chain, - SslCertificateTrust? trust, - ref ProtocolToken alertToken, - out SslPolicyErrors sslPolicyErrors, - out X509ChainStatusFlags chainStatus) - { - sslPolicyErrors = SslPolicyErrors.None; - chainStatus = X509ChainStatusFlags.NoError; - return true; - } -#pragma warning restore IDE0060 - // ── State ───────────────────────────────────────────────────────── public bool IsServer => _context.IsServer; @@ -174,6 +151,11 @@ public SslApplicationProtocol NegotiatedApplicationProtocol return _remoteCertificate; } + if (_externalPendingCert is not null) + { + return _externalPendingCert; + } + if (_securityContext == null || _securityContext.IsInvalid) { return null; @@ -184,11 +166,11 @@ public SslApplicationProtocol NegotiatedApplicationProtocol /// /// Returns the the platform built for the peer /// certificate during handshake, or null if no chain was retained. - /// Only meaningful when - /// is true - /// and the session is awaiting an external validation result. The chain is - /// owned by the session and disposed when the session is disposed or when - /// the validation result is recorded. + /// Only meaningful while the session is awaiting an external validation result + /// (after returned + /// ). The chain is + /// owned by the session and disposed when the session is disposed or when the + /// validation result is recorded. /// public X509Chain? GetRemoteCertificateChain() { @@ -219,19 +201,26 @@ public SslPolicyErrors AcceptWithDefaultValidation() } ProtocolToken alertToken = default; + // 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. bool ok = SslStream.VerifyRemoteCertificateCore( this, _context.Options, _securityContext, ref _remoteCertificate, ref _connectionInfo, - _remoteCertificate, + _externalPendingCert, _externalValidationChain, trust: null, ref alertToken, out SslPolicyErrors sslPolicyErrors, out _); + // 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; } @@ -255,10 +244,28 @@ public void SetRemoteCertificateValidationResult(SslPolicyErrors errors) } _externalValidationPending = false; + _externalValidationResolved = true; - if (errors != SslPolicyErrors.None) + 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; } DisposeExternalValidationChain(); @@ -281,19 +288,10 @@ private void DisposeExternalValidationChain() { X509Chain? chain = _externalValidationChain; _externalValidationChain = null; - if (chain is null) - { - return; - } - - // Dispose elements we own (mirrors the cleanup the OpenSSL CertVerifyCallback - // does when no user CertValidationDelegate is registered). - int elementsCount = chain.ChainElements.Count; - for (int i = 0; i < elementsCount; i++) - { - chain.ChainElements[i].Certificate.Dispose(); - } - chain.Dispose(); + // Match the inline-validation cleanup in OnHandshakeCompleted: dispose the chain context only, + // not individual element certs. The leaf is owned by _remoteCertificate or already disposed via + // _externalPendingCert; intermediate elements are platform-built refs we don't own. + chain?.Dispose(); } /// @@ -368,6 +366,14 @@ public TlsOperationStatus ProcessHandshake( 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."); } @@ -468,11 +474,19 @@ _context.Options.CertificateContext is null && 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) { @@ -490,6 +504,11 @@ _context.Options.CertificateContext is null && : TlsOperationStatus.Complete; } + if (needsCertValidation) + { + return TlsOperationStatus.NeedsCertificateValidation; + } + if (needsCredentials) { return TlsOperationStatus.WantCredentials; @@ -1046,69 +1065,29 @@ private void OnHandshakeCompleted() return; } - if (_useExternalValidation) + // If the caller already resolved validation mid-handshake (OpenSSL 3.0+ + // retry-verify path), don't re-suspend here. + if (_externalValidationResolved) { - // Hand the peer cert + chain back to the caller via - // NeedsCertificateValidation. We build the chain here once and retain it - // until the caller records a result (or the session is disposed) so the - // caller can inspect it on any thread without re-querying the PAL. - X509Chain? chain = null; - _remoteCertificate = CertificateValidationPal.GetRemoteCertificate( - _securityContext, ref chain, _context.Options.CertificateChainPolicy); - _externalValidationChain = chain; - _externalValidationPending = true; return; } - { - X509Chain? chain = null; - try - { - X509Certificate2? cert = CertificateValidationPal.GetRemoteCertificate( - _securityContext, ref chain, _context.Options.CertificateChainPolicy); - ProtocolToken alertToken = default; - SslStream.VerifyRemoteCertificateCore( - this, - _context.Options, - _securityContext, - ref _remoteCertificate, - ref _connectionInfo, - cert, - chain, - trust: null, - ref alertToken, - out _, - out _); - } - finally - { - chain?.Dispose(); - } - } + CaptureRemoteCertificateForExternalValidation(); } - // Invoked by OpenSSL's CertVerifyCallback (via SslAuthenticationOptions.RemoteCertificateValidator) - // when this TlsSession owns the validation flow. - internal bool VerifyRemoteCertificate( - X509Certificate2? certificate, - X509Chain? chain, - SslCertificateTrust? trust, - ref ProtocolToken alertToken, - out SslPolicyErrors sslPolicyErrors, - out X509ChainStatusFlags chainStatus) + // 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() { - return SslStream.VerifyRemoteCertificateCore( - this, - _context.Options, - _securityContext, - ref _remoteCertificate, - ref _connectionInfo, - certificate, - chain, - trust, - ref alertToken, - out sslPolicyErrors, - out chainStatus); + X509Chain? chain = null; + _externalPendingCert = CertificateValidationPal.GetRemoteCertificate( + _securityContext, ref chain, _context.Options.CertificateChainPolicy); + _externalValidationChain = chain; + _externalValidationPending = true; } // Acquire the SafeFreeCredentials the PAL needs for the first ASC/ISC @@ -1187,6 +1166,8 @@ public void Dispose() _disposed = true; DisposeExternalValidationChain(); + _externalPendingCert?.Dispose(); + _externalPendingCert = null; _securityContext?.Dispose(); _securityContext = 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/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index b35527f9f4ad7d..01beb93f1c5b09 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -617,6 +617,227 @@ public async Task ClientSession_AgainstSslStreamServer_HandshakeAndPingPong_Succ } } + // ── 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( 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..3faf018c8ef77b 100644 --- a/src/native/libs/System.Security.Cryptography.Native/opensslshim.h +++ b/src/native/libs/System.Security.Cryptography.Native/opensslshim.h @@ -736,6 +736,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 +1305,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. */ From 0857c66a70ad1d046ab8b0af7ad08cbf53b4d218 Mon Sep 17 00:00:00 2001 From: wfurt Date: Fri, 29 May 2026 15:48:16 -0700 Subject: [PATCH 15/18] TlsSession: add deferred server options (NeedsServerOptions / SetServerOptions) Allow TlsContext.Create to be called with null SslServerAuthenticationOptions so the caller can inspect the peer's ClientHello (SNI, supported versions) before supplying server options. ProcessHandshake parses the first ClientHello, exposes it via TlsSession.ClientHelloInfo, and returns NeedsServerOptions with consumed = 0. The caller then calls SetServerOptions(...) and re-feeds the same input buffer to resume the handshake. - TlsOperationStatus.NeedsServerOptions = 6 - TlsContext.Create(SslServerAuthenticationOptions?) accepts null - TlsSession.ClientHelloInfo / SetServerOptions surface and resume the suspend - ProcessHandshake parses ClientHello via TlsFrameHelper, suspends with consumed=0, and re-returns NeedsServerOptions on subsequent calls until options are supplied; on OpenSSL the retry-verify suspension semantics are restored after ApplyServerOptions - Ref assembly and Stub updated to match --- .../ref/System.Net.Security.cs | 5 +- .../src/System/Net/Security/TlsContext.cs | 45 +++++++-- .../System/Net/Security/TlsOperationStatus.cs | 9 ++ .../System/Net/Security/TlsSession.Stub.cs | 4 + .../src/System/Net/Security/TlsSession.cs | 96 +++++++++++++++++++ 5 files changed, 152 insertions(+), 7 deletions(-) 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 3c6265da95bc39..e5319e890434ac 100644 --- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs +++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs @@ -695,12 +695,13 @@ public enum TlsOperationStatus 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.SslServerAuthenticationOptions? options) { throw null; } public static System.Net.Security.TlsContext Create(System.Net.Security.SslClientAuthenticationOptions options) { throw null; } public void Dispose() { } } @@ -711,6 +712,7 @@ internal TlsSession() { } public bool IsHandshakeComplete { get { throw null; } } public bool HasPendingOutput { get { throw null; } } public string? TargetHostName { get { throw null; } set { } } + public System.Net.Security.SslClientHelloInfo? ClientHelloInfo { get { throw null; } } public System.Security.Authentication.SslProtocols NegotiatedProtocol { get { throw null; } } [System.CLSCompliantAttribute(false)] public System.Net.Security.TlsCipherSuite NegotiatedCipherSuite { get { throw null; } } @@ -725,6 +727,7 @@ internal TlsSession() { } public System.Security.Cryptography.X509Certificates.X509Chain? GetRemoteCertificateChain() { 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; } 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 index f36c9ae7537b4a..ff40d6870e5382 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs @@ -20,6 +20,7 @@ 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 @@ -27,22 +28,54 @@ public sealed class TlsContext : IDisposable // storage that SslStream and TlsSession both read/write via ref. internal SafeFreeCredentials? CredentialsHandle; - private TlsContext(SslAuthenticationOptions options, bool ownsOptions) + 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; - public static TlsContext Create(SslServerAuthenticationOptions options) + /// + /// 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) { - ArgumentNullException.ThrowIfNull(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); + return new TlsContext(bag, ownsOptions: true, hasServerOptions: true); } /// @@ -62,7 +95,7 @@ public static TlsContext Create(SslClientAuthenticationOptions options) ArgumentNullException.ThrowIfNull(options); SslAuthenticationOptions bag = new SslAuthenticationOptions(); bag.UpdateOptions(options); - return new TlsContext(bag, ownsOptions: true); + return new TlsContext(bag, ownsOptions: true, hasServerOptions: false); } // Used by SslStream's TlsSession wedge: share the existing options bag so @@ -71,7 +104,7 @@ public static TlsContext Create(SslClientAuthenticationOptions options) internal static TlsContext WrapShared(SslAuthenticationOptions sharedOptions) { Debug.Assert(sharedOptions != null); - return new TlsContext(sharedOptions, ownsOptions: false); + return new TlsContext(sharedOptions, ownsOptions: false, hasServerOptions: sharedOptions.IsServer); } public void 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 index 6f271a33e49fbf..a1f104f91187b0 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs @@ -48,5 +48,14 @@ public enum TlsOperationStatus /// 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 index a82e120577ba23..6daa214b08cad1 100644 --- 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 @@ -23,6 +23,7 @@ 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); @@ -58,6 +59,9 @@ public SslPolicyErrors AcceptWithDefaultValidation() => 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); 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 index 2ac2efae850cea..2d8a90c9b26625 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -53,6 +53,7 @@ public sealed class TlsSession : IDisposable private X509Chain? _externalValidationChain; private X509Certificate2? _externalPendingCert; private Exception? _externalValidationFault; + private SslClientHelloInfo? _clientHelloInfo; private bool _disposed; private SslConnectionInfo _connectionInfo; private X509Certificate2? _remoteCertificate; @@ -271,6 +272,60 @@ public void SetRemoteCertificateValidationResult(SslPolicyErrors errors) DisposeExternalValidationChain(); } + /// + /// 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) @@ -364,6 +419,12 @@ public TlsOperationStatus ProcessHandshake( 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 @@ -425,6 +486,21 @@ public TlsOperationStatus ProcessHandshake( // 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; @@ -935,6 +1011,26 @@ private void EnsureDecryptScratch(int 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 From 00bcafeb81c68c667c9c156fedb1d08705d8f25d Mon Sep 17 00:00:00 2001 From: Tomas Weinfurt Date: Fri, 29 May 2026 16:13:06 -0700 Subject: [PATCH 16/18] opensslshim: undef OpenSSL 3.0 SSL_set_retry_verify macro OpenSSL 3.0 added a SSL_set_retry_verify(ssl) macro in that expands to SSL_set_verify_result((ssl), X509_V_OK), which collides with the LIGHTUP_FUNCTION dispatch we generate for SSL_set_retry_verify. Undef the macro after both and osslcompat_30.h are pulled in, mirroring the existing #undef ERR_put_error pattern. --- .../System.Security.Cryptography.Native/opensslshim.h | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/native/libs/System.Security.Cryptography.Native/opensslshim.h b/src/native/libs/System.Security.Cryptography.Native/opensslshim.h index 3faf018c8ef77b..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 From 15d3c26c1ba0c6ebead42cc11ca030bc23645e53 Mon Sep 17 00:00:00 2001 From: Tomas Weinfurt Date: Fri, 29 May 2026 16:13:20 -0700 Subject: [PATCH 17/18] TlsSession: keep X509Chain off the public surface; expose peer intermediates - Replace TlsSession.GetRemoteCertificateChain() (X509Chain?) with GetRemoteCertificates() returning an X509Certificate2Collection of just the peer-sent intermediates. The leaf remains available via GetRemoteCertificate(). The platform-built X509Chain no longer escapes the PAL boundary. - CaptureRemoteCertificateForExternalValidation snapshots intermediates out of the platform chain (skipping element 0 = leaf) and disposes the chain immediately. - AcceptWithDefaultValidation builds a fresh local X509Chain, seeds ChainPolicy.ExtraStore with the captured intermediates, runs VerifyRemoteCertificateCore, and disposes the chain in a finally. - TlsSession.Stub.cs updated to the new signature on the unsupported TFMs. - TlsSessionTests driver helpers now call AcceptWithDefaultValidation on NeedsCertificateValidation so the user-supplied validator actually runs. - TlsContext.Create(SslServerAuthenticationOptions) intentionally allows null (deferred SNI-driven resolution); the null-rejection test is renamed and split to assert the deferred-resolution semantics for the server overload and null-throw only on the client overload. --- .../ref/System.Net.Security.cs | 2 +- .../System/Net/Security/TlsOperationStatus.cs | 4 +- .../System/Net/Security/TlsSession.Stub.cs | 2 +- .../src/System/Net/Security/TlsSession.cs | 114 ++++++++++++------ .../tests/FunctionalTests/TlsSessionTests.cs | 20 ++- 5 files changed, 100 insertions(+), 42 deletions(-) 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 e5319e890434ac..bcfd26bd2eaaee 100644 --- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs +++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs @@ -724,7 +724,7 @@ internal TlsSession() { } 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.X509Chain? GetRemoteCertificateChain() { 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) { } 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 index a1f104f91187b0..2cb95afa725962 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsOperationStatus.cs @@ -37,8 +37,8 @@ public enum TlsOperationStatus /// /// 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 optionally the built chain - /// via ), perform validation — + /// (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 /// . 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 index 6daa214b08cad1..53ff5269e4e213 100644 --- 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 @@ -50,7 +50,7 @@ public TlsOperationStatus Shutdown(Span ciphertext, out int produced) => public X509Certificate2? GetRemoteCertificate() => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported); - public X509Chain? GetRemoteCertificateChain() => + public X509Certificate2Collection? GetRemoteCertificates() => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported); public SslPolicyErrors AcceptWithDefaultValidation() => 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 index 2d8a90c9b26625..86758f8f27d543 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -50,7 +50,10 @@ public sealed class TlsSession : IDisposable private bool _suppressInternalCertificateValidation; private bool _externalValidationPending; private bool _externalValidationResolved; - private X509Chain? _externalValidationChain; + // 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; @@ -165,18 +168,18 @@ public SslApplicationProtocol NegotiatedApplicationProtocol } /// - /// Returns the the platform built for the peer - /// certificate during handshake, or null if no chain was retained. - /// Only meaningful while the session is awaiting an external validation result - /// (after returned - /// ). The chain is - /// owned by the session and disposed when the session is disposed or when the - /// validation result is recorded. + /// 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 X509Chain? GetRemoteCertificateChain() + public X509Certificate2Collection? GetRemoteCertificates() { ThrowIfDisposed(); - return _externalValidationChain; + return _externalRemoteCertificates; } /// @@ -201,23 +204,41 @@ public SslPolicyErrors AcceptWithDefaultValidation() "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; - // 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. - bool ok = SslStream.VerifyRemoteCertificateCore( - this, - _context.Options, - _securityContext, - ref _remoteCertificate, - ref _connectionInfo, - _externalPendingCert, - _externalValidationChain, - trust: null, - ref alertToken, - out SslPolicyErrors sslPolicyErrors, - out _); + 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 @@ -269,7 +290,7 @@ public void SetRemoteCertificateValidationResult(SslPolicyErrors errors) _externalPendingCert = null; } - DisposeExternalValidationChain(); + DisposeExternalRemoteCertificates(); } /// @@ -339,14 +360,18 @@ private void ThrowIfPendingExternalValidation() } } - private void DisposeExternalValidationChain() + private void DisposeExternalRemoteCertificates() { - X509Chain? chain = _externalValidationChain; - _externalValidationChain = null; - // Match the inline-validation cleanup in OnHandshakeCompleted: dispose the chain context only, - // not individual element certs. The leaf is owned by _remoteCertificate or already disposed via - // _externalPendingCert; intermediate elements are platform-built refs we don't own. - chain?.Dispose(); + X509Certificate2Collection? certs = _externalRemoteCertificates; + _externalRemoteCertificates = null; + if (certs is null) + { + return; + } + foreach (X509Certificate2 c in certs) + { + c.Dispose(); + } } /// @@ -1182,7 +1207,24 @@ private void CaptureRemoteCertificateForExternalValidation() X509Chain? chain = null; _externalPendingCert = CertificateValidationPal.GetRemoteCertificate( _securityContext, ref chain, _context.Options.CertificateChainPolicy); - _externalValidationChain = chain; + + // 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; } @@ -1261,7 +1303,7 @@ public void Dispose() } _disposed = true; - DisposeExternalValidationChain(); + DisposeExternalRemoteCertificates(); _externalPendingCert?.Dispose(); _externalPendingCert = null; diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 01beb93f1c5b09..9e64c5cd700540 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -82,9 +82,14 @@ public async Task ServerSession_AgainstSslStreamClient_HandshakeAndPingPong_Succ } [Fact] - public void TlsContext_RejectsNullOptions() + public void TlsContext_NullServerOptions_DefersResolution() { - Assert.Throws(() => TlsContext.Create((SslServerAuthenticationOptions)null!)); + // 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!)); } @@ -870,6 +875,13 @@ private static void StepHandshakeInMemory( 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); } @@ -1079,6 +1091,10 @@ private static async Task DriveHandshakeAsync(TlsSession session, Stream transpo case TlsOperationStatus.Complete: continue; + case TlsOperationStatus.NeedsCertificateValidation: + session.AcceptWithDefaultValidation(); + continue; + case TlsOperationStatus.WantWrite: await DrainAsync(session, transport, netOut); continue; From 932a0c34150e3c9213594979b15b5e7260f41e82 Mon Sep 17 00:00:00 2001 From: Tomas Weinfurt Date: Fri, 29 May 2026 16:21:26 -0700 Subject: [PATCH 18/18] TlsSession: remove RequestRenegotiation; keep RequestClientCertificate - Drop TlsSession.RequestRenegotiation from the ref, implementation, and the unsupported-platform stub. The post-handshake client-certificate path remains as RequestClientCertificate, which on TLS 1.2 still issues a HelloRequest internally (matches SslStream's NegotiateClientCertificateAsync surface). - Rename the TLS 1.2 test to ServerSession_RequestClientCertificate_Tls12_ProducesHandshakeBytes so the single client-cert request entry point is the one under test. - Drop 'PoC scope / detached mode' wording from TlsSession and TlsContext doc comments. The class summary now just states that the caller owns I/O. --- .../ref/System.Net.Security.cs | 1 - .../src/System/Net/Security/TlsContext.cs | 4 +-- .../System/Net/Security/TlsSession.Stub.cs | 3 -- .../src/System/Net/Security/TlsSession.cs | 35 +++++-------------- .../tests/FunctionalTests/TlsSessionTests.cs | 18 +++++----- 5 files changed, 20 insertions(+), 41 deletions(-) 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 bcfd26bd2eaaee..173bb2888a4af0 100644 --- a/src/libraries/System.Net.Security/ref/System.Net.Security.cs +++ b/src/libraries/System.Net.Security/ref/System.Net.Security.cs @@ -731,7 +731,6 @@ public void SetServerOptions(System.Net.Security.SslServerAuthenticationOptions 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 System.Net.Security.TlsOperationStatus RequestRenegotiation(System.Span ciphertext, out int produced) { throw null; } public void Dispose() { } } } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs index ff40d6870e5382..6acb898f93162a 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsContext.cs @@ -12,8 +12,8 @@ namespace System.Net.Security /// determined by which factory is used. /// /// - /// PoC scope: holds the resolved options bag. Multi-connection sharing / - /// session cache reuse is not yet wired through; each + /// 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 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 index 53ff5269e4e213..66276738062f48 100644 --- 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 @@ -71,9 +71,6 @@ public void SetServerOptions(SslServerAuthenticationOptions options) => public TlsOperationStatus RequestClientCertificate(Span ciphertext, out int produced) => throw new PlatformNotSupportedException(SR.SystemNetSecurity_PlatformNotSupported); - public TlsOperationStatus RequestRenegotiation(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 index 86758f8f27d543..7048e6a80d155c 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -12,10 +12,10 @@ namespace System.Net.Security { /// /// Non-blocking TLS state machine wrapper around the existing - /// . PoC scope: detached mode only (caller owns I/O). - /// Supported on Linux/FreeBSD (OpenSSL) and Windows (SChannel). Provides - /// , , , - /// and a pending-output queue. + /// . 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. /// /// /// @@ -795,23 +795,7 @@ public TlsOperationStatus Decrypt( } } - // ── Renegotiation / Post-handshake auth ────────────────────────── - - /// - /// Server-side: initiates a TLS renegotiation. On TLS 1.2 this issues - /// a HelloRequest; on TLS 1.3 this issues a post-handshake - /// CertificateRequest (same primitive as , - /// because OpenSSL exposes only the combined operation). - /// - /// - /// 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. - /// - public TlsOperationStatus RequestRenegotiation(Span ciphertext, out int produced) - => RequestClientCertificate(ciphertext, out produced); + // ── Post-handshake auth ────────────────────────────────────────── /// /// Server-side: requests a client certificate from the peer after the @@ -1232,11 +1216,10 @@ private void CaptureRemoteCertificateForExternalValidation() // call. OpenSSL handles credential acquisition lazily inside the PAL, // but SChannel rejects ASC/ISC with a null credentials handle. // - // PoC scope: minimal acquisition path — server requires a pre-set - // CertificateContext (or one resolved via ServerCertSelectionDelegate - // above), and the client connects anonymously. We don't yet integrate - // with SslSessionsCache, the legacy CertSelectionDelegate, or client - // certificate selection. + // 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) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 9e64c5cd700540..a68584c28d8bca 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -1004,7 +1004,7 @@ public async Task ServerSession_ServerCertificateSelectionCallback_InvokedWithSn } [Fact] - public async Task ServerSession_RequestRenegotiation_Tls12_ProducesHelloRequest() + public async Task ServerSession_RequestClientCertificate_Tls12_ProducesHandshakeBytes() { using X509Certificate2 serverCert = TestCertificates.GetServerCertificate(); string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false); @@ -1035,18 +1035,18 @@ public async Task ServerSession_RequestRenegotiation_Tls12_ProducesHelloRequest( Assert.True(session.IsHandshakeComplete); Assert.Equal(SslProtocols.Tls12, session.NegotiatedProtocol); - // Server requests renegotiation. We only verify that the API runs - // and produces a HelloRequest byte stream; driving the full - // renegotiation back through SslStream is intentionally out of - // scope here since SslStream's client-side reneg path needs the - // server to also pump the post-handshake read loop, which the - // standalone TlsSession leaves to the caller. + // 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.RequestRenegotiation(reneg, out int produced); + TlsOperationStatus status = session.RequestClientCertificate(reneg, out int produced); Assert.NotEqual(TlsOperationStatus.Closed, status); - Assert.True(produced > 0, "RequestRenegotiation should emit a HelloRequest."); + Assert.True(produced > 0, "RequestClientCertificate should emit a HelloRequest."); } finally {