File: System\Net\Security\TlsSession.cs
Web Access
Project: src\runtime\src\libraries\System.Net.Security\src\System.Net.Security.csproj (System.Net.Security)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Authentication.ExtendedProtection;
using System.Security.Cryptography.X509Certificates;
// macOS PAL has two SafeDelete* derivatives (SecureTransport + Network.framework)
// and surfaces the base type in ref parameters. Use the base type for the security-context
// field on macOS so it lines up with the PAL ref signatures; other platforms keep the
// derived SafeDeleteSslContext.
#if TARGET_APPLE
using TlsSecurityContext = System.Net.Security.SafeDeleteContext;
#else
using TlsSecurityContext = System.Net.Security.SafeDeleteSslContext;
#endif

namespace System.Net.Security
{
    /// <summary>
    /// Non-blocking TLS state machine that drives handshake and record
    /// processing from caller-supplied byte spans.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Support is provided by the underlying platform, such as SChannel on Windows
    /// and OpenSSL on Linux.
    /// </para>
    /// <para>
    /// 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 <see cref="TlsBufferSession.DrainPendingOutput"/>.
    /// </para>
    /// <para>
    /// Contract: any operation may return <see cref="TlsOperationStatus.DestinationTooSmall"/>
    /// 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.
    /// </para>
    /// </remarks>
    [Experimental(Experimentals.LowLevelTlsDiagId, UrlFormat = Experimentals.SharedUrlFormat)]
    public abstract partial class TlsSession : IDisposable
    {
        // Matches StreamSizes.Default on Unix; conservative upper bound for a
        // single TLS record's plaintext payload.
        internal const int MaxRecordPlaintext = 16354;

        // Nullable until SetContext is called. All operations that depend on a
        // configured context validate this at entry.
        private TlsContext? _context;
        private SslAuthenticationOptions _options = null!;
        private bool _ownsOptions;
        private bool _hasServerOptions;
        private TlsSecurityContext? _securityContext;

        private ArrayBuffer _pendingBuffer = new ArrayBuffer(initialSize: 0, usePool: true);

        // Server-side only: SNI-resolved host name captured from the client's
        // ClientHello. Kept session-local so parallel sessions built from a
        // deferred TlsContext (SNI-dispatching bootstrap) cannot race on the
        // shared _options bag. Empty until the first ClientHello has parsed.
        // On client-side sessions the target host lives on _options.TargetHost
        // (immutable after SetContext, set by the caller via
        // SslClientAuthenticationOptions).
        private string _sessionTargetHost = string.Empty;

        private byte[]? _decryptScratch;

        private bool _isHandshakeComplete;
        private bool _suppressInternalCertificateValidation;
        private bool _externalValidationPending;
        private bool _externalValidationResolved;
        // Set by SetClientCertificateContext after a WantCredentials suspension; consumed by
        // the next ProcessHandshake to allow an empty-input re-entry past the frame guard.
        private bool _resumeAfterCredentials;
        // Set by SetRemoteCertificateValidationResult when the PAL paused mid-handshake
        // pending external certificate validation (SecureTransport on macOS). Consumed by
        // the next ProcessHandshake to allow an empty-input re-entry past the frame guard
        // so the PAL can produce the next handshake flight (or a fatal alert on reject).
        private bool _resumeAfterCertValidation;
        // Intermediate certs the peer sent (chain elements minus the leaf). The platform-built
        // X509Chain itself is never surfaced to TlsSession callers; AcceptWithDefaultValidation
        // rebuilds a fresh chain from this collection at validation time.
        private X509Certificate2Collection? _externalRemoteCertificates;
        private X509Certificate2? _externalPendingCert;
        private Exception? _externalValidationFault;
        private SslClientHelloInfo? _clientHelloInfo;
        private byte[]? _clientHelloBytesBuffered;
        // Session-local credentials handle. Non-null once SetClientCertificateContext
        // has been called; from that point on, this session's PAL calls route through
        // ActiveCredentialsRef() and never touch the shared TlsContext.CredentialsHandle.
        // Disposed when the session is disposed.
        private SafeFreeCredentials? _sessionCredentialsHandle;
        // Session-local view of the CertificateContext. Initialized from _options at
        // SetContext time and every mutation (SetClientCertificateContext, the
        // server-side selector path) routes through SessionCertificateContext so parallel
        // sessions built from the same TlsContext template never race on the shared cert
        // slot. _ownsSessionCertificateContext tracks whether the session itself built
        // this context (only true when constructed via SslStreamCertificateContext.Create
        // in the server-cert-selector path); Dispose releases it iff owned. The PAL
        // signature still reads _options.CertificateContext, so the setter mirrors the
        // new value onto the per-session cloned options bag; that mirror is the single
        // point that goes away when the PAL is later reshaped to consume the session
        // directly.
        private SslStreamCertificateContext? _sessionCertificateContext;
        private bool _ownsSessionCertificateContext;
        private bool _disposed;
        private SslConnectionInfo _connectionInfo;
        private X509Certificate2? _remoteCertificate;
        private int _headerSize;
        private int _trailerSize;
        private int _maxDataSize = MaxRecordPlaintext;

        // Socket-bound mode (optional). When set, the session performs its own
        // non-blocking I/O via Handshake/Read/Write. The session takes ownership
        // of the supplied socket handle and disposes it with the session.
        private SafeSocketHandle? _socketHandle;
        private Socket? _socket;
        private ArrayBuffer _socketInBuffer = new ArrayBuffer(initialSize: 0, usePool: true);

        private protected TlsSession()
        {
        }

        // Called by TlsSocketSession's constructor to bind a socket handle before the
        // session receives any TlsContext. The socket is taken to ownership and disposed
        // with the session. Platforms with a native fd-binding fast path (OpenSSL) take
        // the socket directly; otherwise the socket is wrapped in a managed Socket for
        // the buffered I/O path.
        internal void AttachSocket(SafeSocketHandle socket)
        {
            Debug.Assert(socket != null);
            _socketHandle = socket;

            bool nativeBindingEnabled = false;
            EnableNativeSocketBinding(socket, ref nativeBindingEnabled);
            if (!nativeBindingEnabled)
            {
                _socket = new Socket(socket);
            }
        }

        internal SafeSocketHandle? SocketHandle => _socketHandle;

        private SslStreamCertificateContext? SessionCertificateContext => _sessionCertificateContext;

        private void SetSessionCertificateContext(SslStreamCertificateContext? context, bool takeOwnership)
        {
            if (_ownsSessionCertificateContext && _sessionCertificateContext is not null && !ReferenceEquals(_sessionCertificateContext, context))
            {
                _sessionCertificateContext.ReleaseResources();
            }

            _sessionCertificateContext = context;
            _ownsSessionCertificateContext = takeOwnership && context is not null;

            // Mirror onto the per-session cloned options bag so the PAL (which reads
            // _options.CertificateContext directly) sees the effective value. Also flip
            // the bag's own ownership bit off — session now owns the disposal decision.
            _options.CertificateContext = context;
            _options.OwnsCertificateContext = false;
        }

        private void InitializeFromContext(TlsContext context)
        {
            Debug.Assert(_context is null);
            Debug.Assert(context is not null);
            _context = context;
            _ownsOptions = !context.ShareOptions;
            _options = context.CreateSessionOptions();
            _hasServerOptions = context.TemplateHasServerOptions;

            // Transfer CertificateContext ownership from the per-session options clone
            // to the session so subsequent mutations (SetClientCertificateContext,
            // server-selector build) live entirely on TlsSession's own fields. The bag
            // itself never owns after this point; TlsSession.Dispose is the sole releaser.
            _sessionCertificateContext = _options.CertificateContext;
            _ownsSessionCertificateContext = _options.OwnsCertificateContext;
            _options.OwnsCertificateContext = false;

            OnContextInitialized();
        }

        internal virtual void OnContextInitialized()
        {
        }


        // ── State ─────────────────────────────────────────────────────────

        public bool IsHandshakeComplete => _isHandshakeComplete;

        public bool HasPendingOutput => _pendingBuffer.ActiveLength > 0;

        /// <summary>
        /// Target host name for this session. On the client this is the value the
        /// caller supplied via <see cref="SslClientAuthenticationOptions.TargetHost"/>
        /// (used for SNI and hostname validation). On the server this is the SNI value
        /// parsed from the peer's ClientHello, or <see langword="null"/> if no
        /// ClientHello has been processed yet or the ClientHello carried no SNI
        /// extension. Setting the value on either side overrides the current value;
        /// setting to <see langword="null"/> clears it.
        /// </summary>
        public string? TargetHostName
        {
            get
            {
                ThrowIfContextNotSet();
                if (_context!.IsServer)
                {
                    return string.IsNullOrEmpty(_sessionTargetHost) ? null : _sessionTargetHost;
                }
                return string.IsNullOrEmpty(_options.TargetHost) ? null : _options.TargetHost;
            }
            set
            {
                ThrowIfContextNotSet();
                if (_context!.IsServer)
                {
                    _sessionTargetHost = value ?? string.Empty;
                }
                else
                {
                    _options.TargetHost = value ?? string.Empty;
                }
            }
        }

        public SslProtocols NegotiatedProtocol
        {
            get
            {
                if (!_isHandshakeComplete || _connectionInfo.Protocol == 0)
                {
                    return SslProtocols.None;
                }

                // On Windows (SChannel), the reported protocol value carries
                // client/server direction bits (SP_PROT_TLS1_2_CLIENT == 0x800,
                // SP_PROT_TLS1_2_SERVER == 0x400, etc.). Canonicalize to the
                // managed SslProtocols enum values, matching SslStream.
                SslProtocols proto = (SslProtocols)_connectionInfo.Protocol;
                SslProtocols ret = SslProtocols.None;
#pragma warning disable 0618
                if ((proto & SslProtocols.Ssl2) != 0) ret |= SslProtocols.Ssl2;
                if ((proto & SslProtocols.Ssl3) != 0) ret |= SslProtocols.Ssl3;
#pragma warning restore
#pragma warning disable SYSLIB0039
                if ((proto & SslProtocols.Tls) != 0) ret |= SslProtocols.Tls;
                if ((proto & SslProtocols.Tls11) != 0) ret |= SslProtocols.Tls11;
#pragma warning restore SYSLIB0039
                if ((proto & SslProtocols.Tls12) != 0) ret |= SslProtocols.Tls12;
                if ((proto & SslProtocols.Tls13) != 0) ret |= SslProtocols.Tls13;
                return ret;
            }
        }

        [System.CLSCompliant(false)]
        public TlsCipherSuite NegotiatedCipherSuite =>
            _isHandshakeComplete ? (TlsCipherSuite)_connectionInfo.TlsCipherSuite : default;

        public SslApplicationProtocol NegotiatedApplicationProtocol
        {
            get
            {
                if (!_isHandshakeComplete || _connectionInfo.ApplicationProtocol == null)
                {
                    return default;
                }
                return new SslApplicationProtocol(_connectionInfo.ApplicationProtocol);
            }
        }

        public X509Certificate2? GetRemoteCertificate()
        {
            if (_remoteCertificate is not null)
            {
                return _remoteCertificate;
            }

            if (_externalPendingCert is not null)
            {
                return _externalPendingCert;
            }

            if (_securityContext == null || _securityContext.IsInvalid)
            {
                return null;
            }
            return CertificateValidationPal.GetRemoteCertificate(_securityContext);
        }

        /// <summary>
        /// Returns the intermediate certificates the peer sent alongside its leaf certificate
        /// (the leaf itself is available via <see cref="GetRemoteCertificate"/>), or <c>null</c>
        /// if no intermediates were received. Only meaningful while the session is awaiting an
        /// external validation result (after <see cref="TlsBufferSession.Handshake"/> returned
        /// <see cref="TlsOperationStatus.NeedsCertificateValidation"/>). 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.
        /// </summary>
        public X509Certificate2Collection? GetRemoteCertificates()
        {
            ThrowIfDisposed();
            return _externalRemoteCertificates;
        }

        /// <summary>
        /// Runs the same validation <see cref="SslStream"/> performs (default chain
        /// build plus any user-supplied <see cref="SslClientAuthenticationOptions.RemoteCertificateValidationCallback"/>
        /// on the underlying options), records the result on the session, and returns
        /// the effective <see cref="SslPolicyErrors"/>. Intended for callers that want
        /// <see cref="SslStream"/>-compatible semantics without writing their own
        /// validation logic.
        /// </summary>
        /// <remarks>
        /// Must be called only after <see cref="TlsBufferSession.Handshake"/> returned
        /// <see cref="TlsOperationStatus.NeedsCertificateValidation"/> and before
        /// <see cref="SetRemoteCertificateValidationResult"/> is called.
        /// </remarks>
        public SslPolicyErrors AcceptWithDefaultValidation()
        {
            ThrowIfDisposed();
            if (!_externalValidationPending)
            {
                throw new InvalidOperationException(
                    SR.Format(SR.net_tlssession_validation_not_pending, nameof(AcceptWithDefaultValidation)));
            }

            // 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.
            using X509Chain chain = new X509Chain();
            if (_externalRemoteCertificates is { Count: > 0 } intermediates)
            {
                chain.ChainPolicy.ExtraStore.AddRange(intermediates);
            }

            ProtocolToken alertToken = default;
            SslPolicyErrors sslPolicyErrors = SslPolicyErrors.None;
            bool ok;
            try
            {
                // Pass _externalPendingCert as the candidate cert and an empty _remoteCertificate slot.
                // VerifyRemoteCertificateCore assigns the slot to the candidate on success; the renegotiation
                // shortcut at the top of that method would otherwise dispose our cert if the slot were already
                // populated with the same instance.
                ok = SslStream.VerifyRemoteCertificateCore(
                    this,
                    _options,
                    _securityContext,
                    ref _remoteCertificate,
                    ref _connectionInfo,
                    _externalPendingCert,
                    chain,
                    trust: null,
                    ref alertToken,
                    ref sslPolicyErrors,
                    out _);
            }
            finally
            {
                // Dispose the certificates that chain.Build() populated into ChainElements so
                // they don't linger until GC. Mirrors SslStream.VerifyRemoteCertificate's cleanup
                // when no user callback is provided. ExtraStore entries were supplied by the caller
                // in _externalRemoteCertificates and are intentionally left alone.
                int elementsCount = chain.ChainElements.Count;
                for (int i = 0; i < elementsCount; i++)
                {
                    chain.ChainElements[i].Certificate.Dispose();
                }
            }

            // A user RemoteCertificateValidationCallback can reject an otherwise-clean chain
            // by returning false with sslPolicyErrors == None. Synthesize a non-None failure
            // so SetRemoteCertificateValidationResult takes the reject branch instead of accepting.
            if (!ok && sslPolicyErrors == SslPolicyErrors.None)
            {
                sslPolicyErrors = SslPolicyErrors.RemoteCertificateChainErrors;
            }

            // On success VerifyRemoteCertificateCore set _remoteCertificate = _externalPendingCert, so
            // SetRemoteCertificateValidationResult below leaves it alone. On failure we must dispose the
            // pending cert ourselves because no one adopted it.
            SetRemoteCertificateValidationResult(ok ? SslPolicyErrors.None : sslPolicyErrors);
            return sslPolicyErrors;
        }

        /// <summary>
        /// Records the caller's external certificate-validation result.
        /// <see cref="SslPolicyErrors.None"/> means accept; any other value causes
        /// subsequent calls to <see cref="TlsBufferSession.Handshake"/>, <see cref="TlsBufferSession.Write"/>,
        /// and <see cref="TlsBufferSession.Read"/> to throw <see cref="AuthenticationException"/>.
        /// Must be called exactly once between
        /// <see cref="TlsOperationStatus.NeedsCertificateValidation"/> and the next
        /// session operation.
        /// </summary>
        public void SetRemoteCertificateValidationResult(SslPolicyErrors errors)
        {
            ThrowIfDisposed();
            if (!_externalValidationPending)
            {
                throw new InvalidOperationException(
                    SR.Format(SR.net_tlssession_validation_not_pending, nameof(SetRemoteCertificateValidationResult)));
            }

            _externalValidationPending = false;
            _externalValidationResolved = true;

            // If the PAL paused mid-handshake pending external validation (SecureTransport
            // on macOS returns errSSL{Server,Client}AuthCompleted before any handshake
            // response bytes are produced), the next ProcessHandshake call must be allowed
            // to re-enter the PAL with an empty input to drive the handshake forward
            // (produce ClientKeyExchange/Finished on accept, or a fatal alert on reject).
            // On OpenSSL and SChannel the suspension only fires after _isHandshakeComplete
            // is already true, so this resume flag is a no-op for those PALs.
            if (!_isHandshakeComplete)
            {
                _resumeAfterCertValidation = true;
            }

#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
            // OpenSSL 3.0+ retry-verify path: the handshake paused inside the CertVerifyCallback.
            // Push the verdict to the SafeSslHandle so the next SSL_do_handshake call (driven by
            // the caller's next ProcessHandshake) re-invokes the callback and either accepts the
            // peer cert (Finished is emitted) or rejects it (a fatal alert is emitted).
            PushExternalValidationVerdictToPalIfRetryVerify(errors);
#endif

            if (errors == SslPolicyErrors.None)
            {
                // Caller accepted. Promote the pending cert to the canonical remote-cert slot
                // (unless AcceptWithDefaultValidation already did so).
                if (_remoteCertificate is null)
                {
                    _remoteCertificate = _externalPendingCert;
                    _externalPendingCert = null;
                }
                else
                {
                    // VerifyRemoteCertificateCore adopted the cert into _remoteCertificate. Drop our copy.
                    _externalPendingCert = null;
                }
            }
            else
            {
                // Post-hoc rejection (handshake already wire-complete on OpenSSL 1.1.x or Schannel):
                // surface the fault immediately so subsequent Encrypt/Decrypt throw. For the
                // retry-verify path the handshake is still incomplete and the fault is set when
                // ProcessHandshake drives SSL_do_handshake to failure (so any pending alert bytes
                // are drained to the caller first).
                if (_isHandshakeComplete)
                {
                    _externalValidationFault = new AuthenticationException(SR.net_ssl_io_cert_validation);
                }
                else if (_resumeAfterCertValidation)
                {
                    // Mid-handshake rejection. On OpenSSL 1.1.x / SecureTransport (macOS)
                    // the peer-verify callback took the accept-and-defer path, so the
                    // handshake will still complete on the wire; the caller's Write / Read
                    // must throw AuthenticationException afterwards. Set the fault now, but
                    // do NOT throw it from HandshakeBufferedCore -- let the PAL drive the
                    // handshake to completion silently so the peer doesn't hang waiting
                    // for our Finished. Write / Read guard on ThrowIfPendingExternalValidation
                    // which checks _externalValidationFault. On OpenSSL 3.0+ retry-verify the
                    // fault will instead be set by the natural token-failed branch when
                    // SSL_do_handshake emits the fatal alert.
                    _externalValidationFault = new AuthenticationException(SR.net_ssl_io_cert_validation);
                }

                // VerifyRemoteCertificateCore assigns _remoteCertificate to the candidate before it
                // knows whether the chain validates, so on the reject path the rejected leaf is sitting
                // in the canonical slot. Drop it so GetRemoteCertificate cannot surface a cert the caller
                // explicitly refused. Either _remoteCertificate or _externalPendingCert owns it, not both.
                if (_remoteCertificate is not null && ReferenceEquals(_remoteCertificate, _externalPendingCert))
                {
                    _remoteCertificate = null;
                }
                else
                {
                    _remoteCertificate?.Dispose();
                    _remoteCertificate = null;
                }
                _externalPendingCert?.Dispose();
                _externalPendingCert = null;
            }

            DisposeExternalRemoteCertificates();
        }

#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
        // Client-side only path. When CertVerifyCallback paused the handshake via
        // SSL_set_retry_verify, RetryVerifyAttempted is set on the SafeSslHandle. Stamp
        // the caller's verdict onto the handle so the next SSL_do_handshake (driven by
        // the caller's next ProcessHandshake) re-enters the callback and either accepts
        // the peer cert or emits a fatal alert. No-op on server sessions and on 1.1.x
        // where CertVerifyCallback took the accept-and-defer branch instead of retrying.
        private void PushExternalValidationVerdictToPalIfRetryVerify(SslPolicyErrors errors)
        {
            if (_securityContext is not Microsoft.Win32.SafeHandles.SafeSslHandle sslHandle ||
                !sslHandle.RetryVerifyAttempted)
            {
                return;
            }

            sslHandle.ExternalValidationAccepted = errors == SslPolicyErrors.None;
        }
#endif

        /// <summary>
        /// Server-side only. The parsed ClientHello information, populated once the
        /// ClientHello has been received and stays populated for the lifetime of the
        /// session. Returns <see langword="null"/> before the ClientHello arrives,
        /// on client-side sessions, and on server sessions where ClientHello capture
        /// was disabled via the <c>System.Net.Security.CaptureClientHello</c> AppContext
        /// switch AND options were supplied at <see cref="TlsContext"/> creation time.
        /// </summary>
        public SslClientHelloInfo? ClientHelloInfo
        {
            get
            {
                ThrowIfDisposed();
                return _clientHelloInfo;
            }
        }

        /// <summary>
        /// Server-side only. Returns the number of bytes in the captured raw ClientHello
        /// record (5-byte TLS record header plus the ClientHello handshake message), or
        /// 0 if unavailable. Callers use this to size a destination buffer for
        /// <see cref="TryGetClientHelloBytes"/>.
        /// </summary>
        /// <remarks>
        /// The ClientHello is only captured on server-side sessions and requires the
        /// <c>System.Net.Security.CaptureClientHello</c> AppContext switch to be enabled
        /// (default true). Returns 0 on client-side sessions, before the ClientHello has
        /// been received, or when capture has been disabled.
        /// </remarks>
        public int GetClientHelloLength()
        {
            ThrowIfDisposed();

            ReadOnlySpan<byte> native = default;
            TryGetNativeClientHelloBytes(ref native);
            if (!native.IsEmpty)
            {
                return native.Length;
            }

            return _clientHelloBytesBuffered?.Length ?? 0;
        }

        /// <summary>
        /// Server-side only. Copies the captured raw ClientHello record into
        /// <paramref name="destination"/>. Returns <see langword="true"/> when the full
        /// record was written; <see langword="false"/> if the destination is too small
        /// or the ClientHello is not available.
        /// </summary>
        /// <param name="destination">Buffer that receives the ClientHello bytes.</param>
        /// <param name="bytesWritten">Number of bytes copied. Zero when the method returns false.</param>
        public bool TryGetClientHelloBytes(Span<byte> destination, out int bytesWritten)
        {
            ThrowIfDisposed();

            ReadOnlySpan<byte> source = default;
            TryGetNativeClientHelloBytes(ref source);
            if (source.IsEmpty)
            {
                if (_clientHelloBytesBuffered is null)
                {
                    bytesWritten = 0;
                    return false;
                }
                source = _clientHelloBytesBuffered;
            }

            if (destination.Length < source.Length)
            {
                bytesWritten = 0;
                return false;
            }

            source.CopyTo(destination);
            bytesWritten = source.Length;
            return true;
        }

        /// <summary>
        /// Assigns a <see cref="TlsContext"/> to this session. Must be called at least
        /// once before <see cref="TlsBufferSession.Handshake"/> or its socket-bound
        /// equivalent can make forward progress. May also be called on a server-side
        /// session that suspended with <see cref="TlsOperationStatus.NeedsTlsContext"/>
        /// to steer it onto the resolved per-tenant context.
        /// </summary>
        /// <param name="context">A fully-configured <see cref="TlsContext"/>.</param>
        /// <exception cref="ArgumentNullException">Thrown when <paramref name="context"/> is null.</exception>
        /// <exception cref="ArgumentException">
        /// Thrown when supplying a resolved context after
        /// <see cref="TlsOperationStatus.NeedsTlsContext"/> and the passed context is
        /// not server-side.
        /// </exception>
        /// <exception cref="InvalidOperationException">
        /// Thrown when the session already has a context and is not currently awaiting
        /// server options (i.e., the caller tried to swap a context that was already
        /// fully configured).
        /// </exception>
        public void SetContext(TlsContext context)
        {
            ArgumentNullException.ThrowIfNull(context);
            ThrowIfDisposed();

            if (_context is null)
            {
                InitializeFromContext(context);
                return;
            }

            if (!_context!.IsServer)
            {
                throw new InvalidOperationException(SR.net_tlssession_setcontext_server_only);
            }
            if (!context.IsServer)
            {
                throw new ArgumentException(SR.net_tlssession_context_must_be_server, nameof(context));
            }
            if (_hasServerOptions)
            {
                throw new InvalidOperationException(SR.net_tlssession_server_options_already_supplied);
            }
            if (_clientHelloInfo is null)
            {
                throw new InvalidOperationException(SR.net_tlssession_setcontext_needs_context_first);
            }

            // Ask the supplied context for a session-options bag — this allocates its
            // long-lived SSL_CTX (if not already) and stamps PreallocatedSslContext on
            // the returned bag. Copy those fields (including PreallocatedSslContext) into
            // our session's options so subsequent AllocateSslHandle picks up the passed
            // context's SSL_CTX instead of falling back to the per-session cache path.
            SslAuthenticationOptions serverOpts = context.CreateSessionOptions();
            _options.CopyFrom(serverOpts);
#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
            _options.PreallocatedSslContext = serverOpts.PreallocatedSslContext;
#endif

            // CopyFrom sets _options.OwnsCertificateContext = false and copies the
            // template's CertificateContext reference into the bag. Re-seat our session
            // ownership from the freshly-copied serverOpts (the new template may have
            // brought its own owned context via ServerCertificate), releasing any prior
            // session-owned context. serverOpts itself is a per-session clone that Owns
            // = false, so its live-owner is the source TlsContext template that stays
            // alive across sessions — hence takeOwnership: false here.
            SetSessionCertificateContext(_options.CertificateContext, takeOwnership: false);

            _hasServerOptions = true;

            // The per-tenant options differ from the bootstrap context's template, so
            // credentials must be session-local. Otherwise EnsureCredentialsAcquired
            // would stamp this session's SChannel cred handle into the shared bootstrap
            // TlsContext.CredentialsHandle, and every subsequent session on the same
            // bootstrap would inherit those credentials regardless of which tenant it
            // resolved to (SChannel-only; OpenSSL routes per-tenant SSL_CTX via
            // PreallocatedSslContext on the session-local options bag). Acquire eagerly
            // so any AcquireCredentialsHandle failure surfaces from SetContext,
            // not from an opaque PAL call downstream.
            _sessionCredentialsHandle?.Dispose();
            _sessionCredentialsHandle = SslStreamPal.AcquireCredentialsHandle(_options, false);

            OnServerContextSet();
        }

        /// <summary>
        /// Client-side only. Supplies the certificate context the session should send
        /// in response to the server's CertificateRequest, or <see langword="null"/> to
        /// decline. Intended to resolve a session suspended on
        /// <see cref="TlsOperationStatus.CertificateRequested"/>: callers that need to
        /// fetch a certificate from an out-of-process source (e.g. a key vault) do so
        /// outside the session, then resume the handshake. May also be called before
        /// the first handshake call to seed the client credential when the
        /// <see cref="TlsContext"/> was created without one.
        /// </summary>
        /// <exception cref="InvalidOperationException">
        /// Thrown on a server-side session, or before <see cref="SetContext"/> has been
        /// called.
        /// </exception>
        public void SetClientCertificateContext(SslStreamCertificateContext? context)
        {
            ThrowIfDisposed();
            ThrowIfContextNotSet();

            if (_context!.IsServer)
            {
                throw new InvalidOperationException(SR.net_tlssession_setclientcert_client_only);
            }
            SetSessionCertificateContext(context, takeOwnership: false);

            // Acquire a session-local credentials handle so we don't touch the shared
            // TlsContext.CredentialsHandle, which is used by any concurrent session on
            // the same context (racing/disposing it can cause handshake failures or
            // deliver the wrong certificate on SChannel). ActiveCredentialsRef() will
            // return this session-local handle for subsequent PAL calls. Acquire eagerly
            // so any AcquireCredentialsHandle failure surfaces here, not from an opaque
            // PAL call downstream.
            _sessionCredentialsHandle?.Dispose();
            _sessionCredentialsHandle = SslStreamPal.AcquireCredentialsHandle(_options, false);
            _resumeAfterCredentials = true;
        }

        /// <summary>
        /// Client-side only. Returns the distinguished names of the certificate authorities
        /// the server listed in its TLS 1.2 <c>CertificateRequest</c> or TLS 1.3
        /// <c>certificate_authorities</c> extension. Intended to be called while the session
        /// is suspended on <see cref="TlsOperationStatus.CertificateRequested"/> so the caller can
        /// pick a client certificate that chains to one of the listed CAs. Returns
        /// <see langword="null"/> when no security context exists yet, when the peer sent no
        /// hints, or on a server-side session.
        /// </summary>
        public IReadOnlyList<string>? GetAcceptableIssuers()
        {
            ThrowIfDisposed();

            if (_context is null || _context.IsServer || _securityContext is null)
            {
                return null;
            }

            string[] issuers = CertificateValidationPal.GetRequestCertificateAuthorities(_securityContext);
            return issuers.Length == 0 ? null : issuers;
        }

        private void ThrowIfPendingExternalValidation()
        {
            if (_externalValidationFault is not null)
            {
                throw _externalValidationFault;
            }
            if (_externalValidationPending)
            {
                throw new InvalidOperationException(
                    SR.net_tlssession_validation_result_not_recorded);
            }
        }

        private void DisposeExternalRemoteCertificates()
        {
            X509Certificate2Collection? certs = _externalRemoteCertificates;
            _externalRemoteCertificates = null;
            if (certs is null)
            {
                return;
            }
            foreach (X509Certificate2 c in certs)
            {
                c.Dispose();
            }
        }

        /// <summary>
        /// Returns the local certificate sent to the peer, or <c>null</c> 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 <c>null</c> if
        /// the server did not request a client certificate or the client did
        /// not supply one).
        /// </summary>
        public X509Certificate2? LocalCertificate
        {
            get
            {
                ThrowIfDisposed();
                if (_context!.IsServer)
                {
                    return SessionCertificateContext?.TargetCertificate;
                }

                if (_securityContext == null || _securityContext.IsInvalid)
                {
                    return null;
                }

                if (!CertificateValidationPal.IsLocalCertificateUsed(ActiveCredentialsRef(), _securityContext))
                {
                    return null;
                }

                return SessionCertificateContext?.TargetCertificate;
            }
        }

        /// <summary>
        /// Returns a <see cref="ChannelBinding"/> for the requested
        /// <paramref name="kind"/> derived from the current TLS session, or
        /// <c>null</c> if the binding is unavailable (e.g. handshake not yet
        /// complete, or unsupported binding kind).
        /// </summary>
        public ChannelBinding? GetChannelBinding(ChannelBindingKind kind)
        {
            ThrowIfDisposed();
            if (_securityContext == null || _securityContext.IsInvalid)
            {
                return null;
            }
            return SslStreamPal.QueryContextChannelBinding(_securityContext, kind);
        }

        // ── Handshake ─────────────────────────────────────────────────────

        private protected TlsOperationStatus HandshakeBufferedCore(
            ReadOnlySpan<byte> input,
            Span<byte> output,
            out int bytesConsumed,
            out int bytesWritten)
        {
            ThrowIfDisposed();
            ThrowIfContextNotSet();
            bytesConsumed = 0;
            bytesWritten = 0;

            if (_externalValidationFault is not null)
            {
                // Mid-handshake external-validation reject: on OpenSSL 1.1.x and SecureTransport
                // the peer-verify callback took the accept-and-defer path (no retry-verify), so
                // the wire handshake still needs to complete before the fault surfaces on the
                // caller's Write / Read. Suppress the throw here while the handshake is still
                // in-flight so the PAL can silently drive it to completion; the fault will still
                // fire on Write / Read via ThrowIfPendingExternalValidation. On OpenSSL 3.0+
                // retry-verify the natural PAL failure produces a fatal alert to the peer.
                if (_isHandshakeComplete || !_externalValidationResolved)
                {
                    throw _externalValidationFault;
                }
            }

            if (_externalValidationPending)
            {
                return TlsOperationStatus.NeedsCertificateValidation;
            }

            if (_clientHelloInfo is not null && !_hasServerOptions)
            {
                // The caller previously saw NeedsServerOptions but hasn't supplied options yet.
                return TlsOperationStatus.NeedsTlsContext;
            }

            if (_isHandshakeComplete)
            {
                // Once the caller has resolved external validation, subsequent
                // ProcessHandshake calls on an already-complete session are a
                // no-op signal that the handshake is done (one-call window).
                if (_externalValidationResolved)
                {
                    return TlsOperationStatus.Complete;
                }

                throw new InvalidOperationException(SR.net_tlssession_handshake_already_complete);
            }

            // Drain pending first; do not consume new input while output is owed.
            if (_pendingBuffer.ActiveLength > 0)
            {
                bytesWritten = DrainTo(output);
                return _pendingBuffer.ActiveLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Complete;
            }

            // The PAL state machine — SChannel in particular — must only be handed
            // complete TLS records. SChannel's PAL wrapper reports consumed=input.Length
            // when it returns SEC_E_INCOMPLETE_MESSAGE, which would silently swallow
            // bytes it actually still needs. OpenSSL's BIO accepts partial bytes, but
            // pre-checking the frame here costs nothing extra and keeps the state
            // machine identical across platforms.
            //
            // The only call that legitimately runs with empty input is the very first
            // client-side ISC, which produces the ClientHello, or a client resume after
            // SetClientCertificateContext resolved a prior WantCredentials suspension.
            bool isInitialClientCall = !_context!.IsServer && _securityContext is null;
            bool isCredentialResume = _resumeAfterCredentials;
            _resumeAfterCredentials = false;
            bool isCertValidationResume = _resumeAfterCertValidation;
            _resumeAfterCertValidation = false;
            if (!isInitialClientCall && !isCredentialResume && !isCertValidationResume)
            {
                if (input.Length < TlsFrameHelper.HeaderSize)
                {
                    return TlsOperationStatus.NeedMoreData;
                }

                TlsFrameHeader frameHeader = default;
                if (!TlsFrameHelper.TryGetFrameHeader(input, ref frameHeader))
                {
                    throw new IOException(SR.net_io_decrypt);
                }

                if (input.Length < frameHeader.Length)
                {
                    return TlsOperationStatus.NeedMoreData;
                }
            }

            ProtocolToken token = default;
            token.RentBuffer = true;
            try
            {
                if (_context!.IsServer)
                {
                    // Parse and capture the ClientHello managed-side so the ClientHelloInfo /
                    // TargetHostName / GetClientHelloBytes surface is consistent across paths.
                    // We check on every call while _clientHelloBytesBuffered is null because the
                    // first ProcessHandshake call may pass only a partial CH record - OpenSSL will
                    // allocate _securityContext even on partial input and return WantRead, so we
                    // can't rely on _securityContext being null as our re-entry gate.
                    if (_clientHelloBytesBuffered is null)
                    {
                        SslClientHelloInfo? parsed = TryParseClientHello(input, out int frameLength);
                        if (parsed is not null)
                        {
                            _clientHelloInfo = parsed;
                            if (!string.IsNullOrEmpty(parsed.Value.ServerName))
                            {
                                _sessionTargetHost = parsed.Value.ServerName;
                            }
                            if (frameLength > 0 && frameLength <= input.Length)
                            {
                                _clientHelloBytesBuffered = input.Slice(0, frameLength).ToArray();
                            }
                            // If frameLength is out of range (shouldn't happen after a successful
                            // parse), silently skip capture; the session continues to work,
                            // GetClientHelloLength just reports 0.
                        }
                        else if (_securityContext is null)
                        {
                            // No CH parse-able yet and no PAL context yet - wait for more bytes.
                            return TlsOperationStatus.NeedMoreData;
                        }
                    }

                    // On the very first server-side call, inspect the incoming
                    // ClientHello to surface SNI (TargetHost) and, if the caller
                    // supplied a ServerCertificateSelectionCallback, resolve the
                    // server certificate from it before AllocateSslHandle runs.
                    if (_securityContext is null)
                    {
                        if (!_hasServerOptions)
                        {
                            // Deferred / SNI-callback flow: caller resolves via SetContext.
                            // Leave input unconsumed; the caller re-feeds the same bytes on resume.
                            return TlsOperationStatus.NeedsTlsContext;
                        }

                        bool needsCertResolution =
                            SessionCertificateContext is null &&
                            _options.ServerCertSelectionDelegate is not null;

                        if (needsCertResolution && !ResolveServerCertificateFromClientHello(input))
                        {
                            // Need more bytes to parse the ClientHello (and run the
                            // ServerCertificateSelectionCallback).
                            return TlsOperationStatus.NeedMoreData;
                        }
                    }

                    EnsureCredentialsAcquired();

                    token = SslStreamPal.AcceptSecurityContext(
                        ref ActiveCredentialsRef(),
                        ref _securityContext,
                        input,
                        out bytesConsumed,
                        _options);
                }
                else
                {
                    EnsureCredentialsAcquired();

                    string hostName = TargetHostNameHelper.NormalizeHostName(_options.TargetHost);
                    token = SslStreamPal.InitializeSecurityContext(
                        ref ActiveCredentialsRef(),
                        ref _securityContext,
                        hostName,
                        input,
                        out bytesConsumed,
                        _options);
                }

                // Stage any handshake bytes the PAL produced.
                if (token.Size > 0)
                {
                    Debug.Assert(token.Payload != null);
                    AppendPending(new ReadOnlySpan<byte>(token.Payload, 0, token.Size));
                }

                // Server-side ALPN selection ceremony (SChannel and SecureTransport).
                // After parsing the ClientHello the PAL pauses and asks the caller to
                // pick the application protocol before resuming. We re-enter ASC with
                // an empty input so the PAL can generate the ServerHello carrying the
                // selected ALPN value.
                if (token.Status.ErrorCode == SecurityStatusPalErrorCode.HandshakeStarted)
                {
                    ReadOnlySpan<byte> rawAlpn = ReadOnlySpan<byte>.Empty;
                    TlsFrameHelper.TlsFrameInfo frameInfo = default;
                    if (TlsFrameHelper.TryGetFrameInfo(input, ref frameInfo,
                            TlsFrameHelper.ProcessingOptions.ApplicationProtocol | TlsFrameHelper.ProcessingOptions.RawApplicationProtocol) &&
                        frameInfo.RawApplicationProtocols is byte[] rawAlpnBytes)
                    {
                        rawAlpn = rawAlpnBytes;
                    }

                    SecurityStatusPal selStatus = SslStreamPal.SelectApplicationProtocol(
                        _context!.CredentialsHandle,
                        _securityContext!,
                        _options,
                        rawAlpn);

                    if (selStatus.ErrorCode != SecurityStatusPalErrorCode.OK)
                    {
                        throw new AuthenticationException(SR.net_auth_SSPI, selStatus.Exception);
                    }

                    token.ReleasePayload();

                    if (_context!.IsServer)
                    {
                        token = SslStreamPal.AcceptSecurityContext(
                            ref ActiveCredentialsRef(),
                            ref _securityContext,
                            ReadOnlySpan<byte>.Empty,
                            out _,
                            _options);
                    }
                    else
                    {
                        string hostName = TargetHostNameHelper.NormalizeHostName(_options.TargetHost);
                        token = SslStreamPal.InitializeSecurityContext(
                            ref ActiveCredentialsRef(),
                            ref _securityContext,
                            hostName,
                            ReadOnlySpan<byte>.Empty,
                            out _,
                            _options);
                    }

                    if (token.Size > 0)
                    {
                        Debug.Assert(token.Payload != null);
                        AppendPending(new ReadOnlySpan<byte>(token.Payload, 0, token.Size));
                    }
                }

                if (token.Failed &&
                    token.Status.ErrorCode != SecurityStatusPalErrorCode.CredentialsNeeded &&
                    token.Status.ErrorCode != SecurityStatusPalErrorCode.CertValidationNeeded)
                {
                    Exception authExc = new AuthenticationException(SR.net_auth_SSPI, token.GetException());

                    // OpenSSL queued a TLS alert in the BIO during the failing SSL_do_handshake
                    // (e.g. bad_certificate after the client-side retry-verify callback rejected
                    // the peer). Drain the alert to the caller's output buffer before throwing so
                    // the peer observes an AuthenticationException instead of a connection reset.
                    // The fault is re-raised on the next ProcessHandshake call once the queue is
                    // empty. Only fires on the client path today; server-side never reaches this
                    // branch for external-validation reasons because CertVerifyCallback
                    // accepts-and-defers (see gating in Interop.OpenSsl.CertVerifyCallback).
                    if (_pendingBuffer.ActiveLength > 0)
                    {
                        bytesWritten = DrainTo(output);
                        _externalValidationFault = authExc;
                        return TlsOperationStatus.DestinationTooSmall;
                    }

                    throw authExc;
                }

                bool done = token.Status.ErrorCode == SecurityStatusPalErrorCode.OK;
                bool needsCredentials = token.Status.ErrorCode == SecurityStatusPalErrorCode.CredentialsNeeded;
                bool needsCertValidation = token.Status.ErrorCode == SecurityStatusPalErrorCode.CertValidationNeeded;

                if (done)
                {
                    OnHandshakeCompleted();
                }
                else if (needsCertValidation)
                {
                    // PAL paused mid-handshake awaiting external certificate validation.
                    // Capture the peer cert + chain so the caller can validate, then return
                    // NeedsCertificateValidation. Not used by the current OpenSSL or SChannel
                    // paths but kept as a generic suspension hook.
                    CaptureRemoteCertificateForExternalValidation();
                }

                if (_pendingBuffer.ActiveLength > 0)
                {
                    bytesWritten = DrainTo(output);
                    if (_pendingBuffer.ActiveLength > 0)
                    {
                        return TlsOperationStatus.DestinationTooSmall;
                    }
                }

                if (done)
                {
                    return _externalValidationPending
                        ? TlsOperationStatus.NeedsCertificateValidation
                        : TlsOperationStatus.Complete;
                }

                if (needsCertValidation)
                {
                    return TlsOperationStatus.NeedsCertificateValidation;
                }

                if (needsCredentials)
                {
                    return TlsOperationStatus.CertificateRequested;
                }

                // SChannel consumes one TLS record per AcceptSecurityContext/
                // InitializeSecurityContext call (OpenSSL typically consumes the
                // whole input via the BIO). When the PAL accepted bytes but the
                // caller still has more buffered, return Complete so the driver
                // re-enters us with the remainder instead of blocking on a network
                // read the peer will never satisfy (e.g. server seeing CKE+CCS+
                // Finished in one TCP read during a TLS 1.2 handshake).
                if (bytesConsumed > 0 && bytesConsumed < input.Length)
                {
                    return TlsOperationStatus.Complete;
                }

                return TlsOperationStatus.NeedMoreData;
            }
            finally
            {
                token.ReleasePayload();
            }
        }

        private protected TlsOperationStatus WriteBufferedCore(
            ReadOnlySpan<byte> plaintext,
            Span<byte> ciphertext,
            out int bytesConsumed,
            out int bytesWritten)
        {
            ThrowIfDisposed();
            ThrowIfPendingExternalValidation();
            bytesConsumed = 0;
            bytesWritten = 0;

            if (!_isHandshakeComplete)
            {
                throw new InvalidOperationException(SR.net_tlssession_handshake_not_complete);
            }

            if (_pendingBuffer.ActiveLength > 0)
            {
                bytesWritten = DrainTo(ciphertext);
                return _pendingBuffer.ActiveLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Complete;
            }

            if (plaintext.IsEmpty)
            {
                return TlsOperationStatus.Complete;
            }

            int chunk = Math.Min(plaintext.Length, _maxDataSize);
            byte[] rented = ArrayPool<byte>.Shared.Rent(chunk);
            try
            {
                plaintext.Slice(0, chunk).CopyTo(rented);

                ProtocolToken token = SslStreamPal.EncryptMessage(
                    _securityContext!,
                    new ReadOnlyMemory<byte>(rented, 0, chunk),
                    _headerSize,
                    _trailerSize);

                try
                {
                    if (token.Status.ErrorCode != SecurityStatusPalErrorCode.OK)
                    {
                        throw new IOException(SR.net_io_encrypt, SslStreamPal.GetException(token.Status));
                    }

                    bytesConsumed = chunk;

                    if (token.Size > 0)
                    {
                        Debug.Assert(token.Payload != null);
                        AppendPending(new ReadOnlySpan<byte>(token.Payload, 0, token.Size));
                    }
                }
                finally
                {
                    token.ReleasePayload();
                }
            }
            finally
            {
                ArrayPool<byte>.Shared.Return(rented);
            }

            bytesWritten = DrainTo(ciphertext);
            return _pendingBuffer.ActiveLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Complete;
        }

        // ── Decrypt ───────────────────────────────────────────────────────

        private protected TlsOperationStatus ReadBufferedCore(
            ReadOnlySpan<byte> ciphertext,
            Span<byte> plaintext,
            out int bytesConsumed,
            out int bytesWritten)
        {
            ThrowIfDisposed();
            ThrowIfPendingExternalValidation();
            bytesConsumed = 0;
            bytesWritten = 0;

            if (!_isHandshakeComplete)
            {
                throw new InvalidOperationException(SR.net_tlssession_handshake_not_complete);
            }

            if (_pendingBuffer.ActiveLength > 0)
            {
                // Caller must drain before we accept new input.
                return TlsOperationStatus.DestinationTooSmall;
            }

            // Need at least a frame header. If the caller didn't provide a full frame, the PAL
            // may still have plaintext buffered internally — ciphertext absorbed by OpenSSL's
            // BIO during ProcessHandshake (e.g. the peer coalesced its Finished with the first
            // app-data record into one TCP segment) or a record consumed but not yet decrypted
            // by a prior Decrypt call. On platforms whose PAL maintains such a buffer, probe it
            // with an empty input before asking the caller for more wire bytes; otherwise the
            // session deadlocks waiting on data the peer already sent.
            if (ciphertext.Length < TlsFrameHelper.HeaderSize)
            {
                return TryDrainBufferedPlaintext(plaintext, out bytesWritten);
            }

            TlsFrameHeader header = default;
            if (!TlsFrameHelper.TryGetFrameHeader(ciphertext, ref header))
            {
                throw new IOException(SR.net_io_decrypt);
            }

            int frameSize = header.Length;
            if (ciphertext.Length < frameSize)
            {
                return TryDrainBufferedPlaintext(plaintext, out bytesWritten);
            }

            // PAL decrypts in place; copy into a writable scratch buffer.
            EnsureDecryptScratch(frameSize);
            ciphertext.Slice(0, frameSize).CopyTo(_decryptScratch);

            SecurityStatusPal status = SslStreamPal.DecryptMessage(
                _securityContext!,
                _decryptScratch.AsSpan(0, frameSize),
                plaintext,
                out int decBytesWritten,
                out int decLeftoverOffset,
                out int decLeftoverLength);

            switch (status.ErrorCode)
            {
                case SecurityStatusPalErrorCode.OK:
                    bytesConsumed = frameSize;
                    // Linux/macOS PALs write the plaintext directly into the destination span and
                    // (if it didn't fit, or the PAL prefers in-place) leave overflow in the encrypted
                    // span at leftoverOffset/leftoverLength. SChannel always decrypts in place and
                    // reports bytesWritten = 0 with leftoverOffset/leftoverLength pointing at the
                    // plaintext inside the encrypted span. Unify by appending the leftover slice
                    // after whatever was written into destination.
                    int needed = decBytesWritten + decLeftoverLength;
                    if (needed > plaintext.Length)
                    {
                        throw new InvalidOperationException(
                            SR.Format(SR.net_tlssession_plaintext_buffer_too_small, needed, plaintext.Length));
                    }
                    if (decLeftoverLength > 0)
                    {
                        _decryptScratch.AsSpan(decLeftoverOffset, decLeftoverLength)
                            .CopyTo(plaintext.Slice(decBytesWritten));
                    }
                    bytesWritten = needed;
                    return TlsOperationStatus.Complete;

                case SecurityStatusPalErrorCode.ContextExpired:
                case SecurityStatusPalErrorCode.ContextExpiredError:
                    bytesConsumed = frameSize;
                    return TlsOperationStatus.Closed;

                case SecurityStatusPalErrorCode.Renegotiate:
                    // SChannel surfaces SEC_I_RENEGOTIATE for two distinct cases:
                    //  - TLS 1.2 peer-initiated renegotiation (HelloRequest).
                    //  - TLS 1.3 post-handshake messages (NewSessionTicket,
                    //    KeyUpdate, post-handshake CertificateRequest).
                    // In either case the decrypted payload is the inner handshake
                    // record that must be fed back into ASC/ISC so SChannel can
                    // update its internal state. If we don't, the next DecryptMessage
                    // returns SEC_E_CONTEXT_EXPIRED because the context is stuck.
                    bytesConsumed = frameSize;
                    if (decLeftoverLength > 0)
                    {
                        ProcessPostHandshakeMessage(_decryptScratch.AsSpan(decLeftoverOffset, decLeftoverLength));
                    }
                    // Return Complete (not WantRead): we consumed input bytes but
                    // produced no plaintext. The caller's loop should re-enter to
                    // process any remaining buffered ciphertext (e.g. application
                    // data that arrived in the same TCP segment as the NST).
                    return TlsOperationStatus.Complete;

                default:
                    throw new IOException(SR.net_io_decrypt, SslStreamPal.GetException(status));
            }
        }

        // Empty-input probe used when the caller's buffer doesn't yet hold a complete TLS
        // frame. On OpenSSL the PAL's record layer may still have plaintext queued from a
        // prior call (handshake input that included trailing app-data, or a second record
        // coalesced into the same TCP segment); calling DecryptMessage with an empty span
        // surfaces it. On SChannel / SecureTransport the equivalent buffer does not exist,
        // so the probe is skipped and the caller is asked for more bytes instead. The
        // bytesConsumed out-parameter on the public Decrypt method is necessarily 0 here:
        // no caller bytes were taken.
        private TlsOperationStatus TryDrainBufferedPlaintext(Span<byte> plaintext, out int bytesWritten)
        {
            bytesWritten = 0;

            if (!OperatingSystem.IsLinux() && !OperatingSystem.IsFreeBSD() && !OperatingSystem.IsAndroid())
            {
                return TlsOperationStatus.NeedMoreData;
            }

            SecurityStatusPal status = SslStreamPal.DecryptMessage(
                _securityContext!,
                Span<byte>.Empty,
                plaintext,
                out int decBytesWritten,
                out int decLeftoverOffset,
                out int decLeftoverLength);

            if (status.ErrorCode != SecurityStatusPalErrorCode.OK)
            {
                // Anything other than success here means there's nothing to drain — the PAL
                // is genuinely waiting on wire bytes. Surface as WantRead; fatal errors will
                // resurface on the next regular Decrypt call with real ciphertext.
                return TlsOperationStatus.NeedMoreData;
            }

            int produced = decBytesWritten + decLeftoverLength;
            if (produced == 0)
            {
                return TlsOperationStatus.NeedMoreData;
            }

            if (produced > plaintext.Length)
            {
                throw new InvalidOperationException(
                    SR.Format(SR.net_tlssession_plaintext_buffer_too_small, produced, plaintext.Length));
            }

            if (decLeftoverLength > 0)
            {
                // PAL stashed overflow in the (empty) input span — impossible here, but mirror
                // the main Decrypt path for symmetry. With Span<byte>.Empty as input, the OpenSSL
                // PAL has nowhere to stash leftover and won't take this path.
                _decryptScratch.AsSpan(decLeftoverOffset, decLeftoverLength)
                    .CopyTo(plaintext.Slice(decBytesWritten));
            }

            bytesWritten = produced;
            return TlsOperationStatus.Complete;
        }

        // ── Post-handshake auth ──────────────────────────────────────────

        /// <summary>
        /// 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.
        /// </summary>
        /// <remarks>
        /// <para>
        /// The generated handshake bytes are staged into the pending-output
        /// buffer (drained into <paramref name="ciphertext"/>). The caller
        /// must then continue normal <see cref="TlsBufferSession.Read"/> / <see cref="TlsBufferSession.Write"/>
        /// operations; OpenSSL processes the peer's response transparently
        /// inside subsequent <c>SSL_read</c> calls. Once the peer's
        /// certificate has been received, it becomes observable via
        /// <see cref="GetRemoteCertificate"/>.
        /// </para>
        /// </remarks>
        private protected TlsOperationStatus RequestClientCertificateBufferedCore(Span<byte> ciphertext, out int bytesWritten)
        {
            ThrowIfDisposed();
            bytesWritten = 0;

#if TARGET_APPLE
            // SecureTransport does not expose a post-handshake client-authentication
            // path, and Network.framework does not provide renegotiation primitives.
            throw new PlatformNotSupportedException(SR.net_ssl_renegotiate_not_supported);
#else
            if (!_context!.IsServer)
            {
                throw new InvalidOperationException(SR.net_tlssession_request_client_cert_server_only);
            }

            if (!_isHandshakeComplete || _securityContext == null || _securityContext.IsInvalid)
            {
                throw new InvalidOperationException(SR.net_tlssession_handshake_not_complete);
            }

            if (_pendingBuffer.ActiveLength == 0)
            {
                ProtocolToken token = SslStreamPal.Renegotiate(
                    ref ActiveCredentialsRef(),
                    ref _securityContext!,
                    _options);
                try
                {
                    if (token.Failed)
                    {
                        throw new AuthenticationException(SR.net_auth_SSPI, token.GetException());
                    }

                    if (token.Size > 0)
                    {
                        Debug.Assert(token.Payload != null);
                        AppendPending(new ReadOnlySpan<byte>(token.Payload, 0, token.Size));
                    }
                }
                finally
                {
                    token.ReleasePayload();
                }
            }

            bytesWritten = DrainTo(ciphertext);
            return _pendingBuffer.ActiveLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Complete;
#endif
        }

        // ── Shutdown ──────────────────────────────────────────────────────

        private bool _shutdownSent;

        /// <summary>
        /// Initiates a TLS close_notify shutdown and stages the resulting alert
        /// record into the pending-output buffer (drained into <paramref name="ciphertext"/>).
        /// Subsequent calls drain any remaining shutdown output.
        /// </summary>
        /// <remarks>
        /// Returns <see cref="TlsOperationStatus.DestinationTooSmall"/> if the caller must
        /// drain more output before the shutdown record is fully written;
        /// otherwise <see cref="TlsOperationStatus.Closed"/> once all bytes have
        /// been handed to the caller.
        /// </remarks>
        private protected TlsOperationStatus ShutdownBufferedCore(Span<byte> ciphertext, out int bytesWritten)
        {
            ThrowIfDisposed();
            bytesWritten = 0;

            if (_securityContext == null || _securityContext.IsInvalid)
            {
                return TlsOperationStatus.Closed;
            }

            if (!_shutdownSent)
            {
                _shutdownSent = true;

                SecurityStatusPal status = SslStreamPal.ApplyShutdownToken(_securityContext);
                if (status.ErrorCode != SecurityStatusPalErrorCode.OK)
                {
                    throw new IOException(SR.net_io_encrypt, SslStreamPal.GetException(status));
                }

                // Drive one step to extract the close_notify bytes the PAL queued
                // into the underlying BIO. Input is empty; we only care about
                // any output the PAL produces.
                ProtocolToken token = default;
                token.RentBuffer = true;
                try
                {
                    if (_context!.IsServer)
                    {
                        token = SslStreamPal.AcceptSecurityContext(
                            ref ActiveCredentialsRef(),
                            ref _securityContext,
                            ReadOnlySpan<byte>.Empty,
                            out _,
                            _options);
                    }
                    else
                    {
                        string hostName = TargetHostNameHelper.NormalizeHostName(_options.TargetHost);
                        token = SslStreamPal.InitializeSecurityContext(
                            ref ActiveCredentialsRef(),
                            ref _securityContext,
                            hostName,
                            ReadOnlySpan<byte>.Empty,
                            out _,
                            _options);
                    }

                    if (token.Size > 0)
                    {
                        Debug.Assert(token.Payload != null);
                        AppendPending(new ReadOnlySpan<byte>(token.Payload, 0, token.Size));
                    }
                }
                finally
                {
                    token.ReleasePayload();
                }
            }

            bytesWritten = DrainTo(ciphertext);
            return _pendingBuffer.ActiveLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Closed;
        }

        // ── Pending output ────────────────────────────────────────────────

        private protected TlsOperationStatus DrainPendingOutputCore(Span<byte> ciphertext, out int bytesWritten)
        {
            ThrowIfDisposed();
            bytesWritten = DrainTo(ciphertext);
            return _pendingBuffer.ActiveLength > 0 ? TlsOperationStatus.DestinationTooSmall : TlsOperationStatus.Complete;
        }

        // ── Internals ─────────────────────────────────────────────────────

        private void AppendPending(ReadOnlySpan<byte> data)
        {
            if (data.IsEmpty)
            {
                return;
            }

            _pendingBuffer.EnsureAvailableSpace(data.Length);
            data.CopyTo(_pendingBuffer.AvailableSpan);
            _pendingBuffer.Commit(data.Length);
        }

        private int DrainTo(Span<byte> output)
        {
            int n = Math.Min(output.Length, _pendingBuffer.ActiveLength);
            if (n == 0)
            {
                return 0;
            }

            _pendingBuffer.ActiveSpan.Slice(0, n).CopyTo(output);
            _pendingBuffer.Discard(n);
            return n;
        }

        private void EnsureDecryptScratch(int size)
        {
            if (_decryptScratch == null || _decryptScratch.Length < size)
            {
                if (_decryptScratch != null)
                {
                    ArrayPool<byte>.Shared.Return(_decryptScratch);
                }
                _decryptScratch = ArrayPool<byte>.Shared.Rent(size);
            }
        }

        private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);

        private void ThrowIfContextNotSet()
        {
            if (_context is null)
            {
                throw new InvalidOperationException(SR.net_ssl_tlssession_context_not_set);
            }
        }

        // Server-side: parses the ClientHello and returns a populated
        // SslClientHelloInfo (SNI + supported versions), or null if more bytes
        // are needed or the record is not a ClientHello. Used by the
        // deferred-options path; does not mutate session state.
        private static SslClientHelloInfo? TryParseClientHello(ReadOnlySpan<byte> input, out int frameLength)
        {
            frameLength = 0;
            TlsFrameHelper.TlsFrameInfo frameInfo = default;
            if (!TlsFrameHelper.TryGetFrameInfo(input, ref frameInfo))
            {
                return null;
            }

            if (frameInfo.HandshakeType != TlsHandshakeType.ClientHello)
            {
                return null;
            }

            frameLength = frameInfo.Header.Length;
            return new SslClientHelloInfo(frameInfo.TargetName ?? string.Empty, frameInfo.SupportedVersions);
        }

        // Server-side SNI + certificate selection. Parses the ClientHello to
        // extract the server_name extension (SNI) and, if a
        // ServerCertificateSelectionCallback was supplied and no static
        // CertificateContext has been resolved yet, invokes the callback to
        // pick the cert. Mirrors the path SslStream takes in
        // ReceiveBlobAsync/AcquireServerCredentials.
        private bool ResolveServerCertificateFromClientHello(ReadOnlySpan<byte> 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))
            {
                _sessionTargetHost = frameInfo.TargetName;
            }

            ServerCertificateSelectionCallback? selector = _options.ServerCertSelectionDelegate;
            if (selector is null || SessionCertificateContext is not null)
            {
                return true;
            }

            X509Certificate? selected = selector(this, _sessionTargetHost);
            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);
            }

            SetSessionCertificateContext(
                SslStreamCertificateContext.Create(withKey, additionalCertificates: null, offline: false, trust: null, noOcspFetch: true),
                takeOwnership: true);
            return true;
        }

        // ── Internal surface for the SslStream wedge (Linux/FreeBSD only) ─

        // Direct accessors used by SslStream to mirror state into its own fields after
        // each handshake step. Both handles are owned by this TlsSession; SslStream
        // observes them via the mirror but does not dispose them.
        // Set by the SslStream wedge: SslStream owns the validation flow and will
        // invoke the user callback itself with the SslStream as the sender. Skipping
        // here avoids invoking the callback twice and avoids handing TlsSession to
        // user code that expects SslStream.
        internal bool SuppressInternalCertificateValidation
        {
            get => _suppressInternalCertificateValidation;
            set => _suppressInternalCertificateValidation = value;
        }

        internal TlsSecurityContext? SecurityContext => _securityContext;
        internal TlsContext Context => _context!;
        internal SafeFreeCredentials? CredentialsHandle
        {
            get => ActiveCredentialsRef();
            set
            {
                if (_sessionCredentialsHandle is not null)
                {
                    _sessionCredentialsHandle = value;
                }
                else
                {
                    _context!.CredentialsHandle = value;
                }
            }
        }

        // Returns a ref to the credentials handle this session should use for its next
        // PAL call. When _sessionCredentialsHandle is set (via SetClientCertificateContext),
        // it takes precedence; otherwise the shared TlsContext.CredentialsHandle is used.
        // Class instance refs have unrestricted lifetime, no [UnscopedRef] needed.
        private ref SafeFreeCredentials? ActiveCredentialsRef()
            => ref (_sessionCredentialsHandle is not null
                    ? ref _sessionCredentialsHandle
                    : ref _context!.CredentialsHandle);

        // SslStream's GenerateToken replacement. Drives one ASC/ISC step via PAL and
        // updates internal handshake-complete state. Returns the raw PAL token so the
        // caller can preserve existing ProtocolToken-based plumbing (alerts, error
        // mapping, NetEventSource).
        internal ProtocolToken HandshakeStepForSslStream(ReadOnlySpan<byte> input, out int bytesConsumed)
        {
            ThrowIfDisposed();

            ProtocolToken token;
            if (_context!.IsServer)
            {
                token = SslStreamPal.AcceptSecurityContext(
                    ref ActiveCredentialsRef(),
                    ref _securityContext,
                    input,
                    out bytesConsumed,
                    _options);
            }
            else
            {
                string hostName = TargetHostNameHelper.NormalizeHostName(_options.TargetHost);
                token = SslStreamPal.InitializeSecurityContext(
                    ref ActiveCredentialsRef(),
                    ref _securityContext,
                    hostName,
                    input,
                    out bytesConsumed,
                    _options);
            }

            if (token.Status.ErrorCode == SecurityStatusPalErrorCode.OK)
            {
                OnHandshakeCompleted();
            }

            return token;
        }

        private void OnHandshakeCompleted()
        {
            _isHandshakeComplete = true;
            SslStreamPal.QueryContextConnectionInfo(_securityContext!, ref _connectionInfo);
            SslStreamPal.QueryContextStreamSizes(_securityContext!, out StreamSizes streamSizes);
            _headerSize = streamSizes.Header;
            _trailerSize = streamSizes.Trailer;
            if (streamSizes.MaximumMessage > 0)
            {
                _maxDataSize = Math.Min(streamSizes.MaximumMessage, MaxRecordPlaintext);
            }

            // Invoke remote-certificate validation callback (mirrors SslStream).
            // Client: always validate the server cert.
            // Server: always suspend so the caller's RemoteCertificateValidationCallback runs
            // (it must see optional client certs and the no-cert case alike — only the
            // RemoteCertificateNotAvailable error is suppressed in VerifyRemoteCertificateCore
            // when there is no user callback and RemoteCertRequired is false).
            if (_suppressInternalCertificateValidation)
            {
                return;
            }

            // If the caller already resolved validation via a prior suspension
            // (defensive — current OpenSSL/SChannel paths only suspend once via
            // the post-handshake hook below), don't re-suspend here.
            if (_externalValidationResolved)
            {
                return;
            }

            CaptureRemoteCertificateForExternalValidation();
        }

        // Capture the peer certificate and chain so the caller can perform validation
        // out of band. Keeps the cert in _externalPendingCert (not _remoteCertificate)
        // so VerifyRemoteCertificateCore's renegotiation shortcut doesn't dispose it
        // when AcceptWithDefaultValidation runs.
        private void CaptureRemoteCertificateForExternalValidation()
        {
            X509Chain? chain = null;
            _externalPendingCert = CertificateValidationPal.GetRemoteCertificate(
                _securityContext, ref chain, _options.CertificateChainPolicy);

            // Snapshot the peer-sent intermediates into a flat collection and dispose the
            // platform-built chain immediately. The chain instance never escapes the PAL
            // boundary into TlsSession state or its public surface.
            if (chain is not null)
            {
                if (chain.ChainElements.Count > 1)
                {
                    X509Certificate2Collection intermediates = new X509Certificate2Collection();
                    for (int i = 1; i < chain.ChainElements.Count; i++)
                    {
                        intermediates.Add(new X509Certificate2(chain.ChainElements[i].Certificate));
                    }
                    _externalRemoteCertificates = intermediates;
                }
                chain.Dispose();
            }

            _externalValidationPending = true;
        }

        // Acquire the SafeFreeCredentials the PAL needs for the first ASC/ISC
        // call. OpenSSL handles credential acquisition lazily inside the PAL,
        // but SChannel rejects ASC/ISC with a null credentials handle.
        //
        // Server requires a pre-set CertificateContext (or one resolved via
        // ServerCertSelectionDelegate above); the client connects anonymously.
        // SslSessionsCache, the legacy CertSelectionDelegate, and client
        // certificate selection are not yet integrated.
        private void EnsureCredentialsAcquired()
        {
            // If SetContext or SetClientCertificateContext already produced a
            // session-local handle, ActiveCredentialsRef() will route the PAL
            // through it. Skip touching the shared TlsContext.CredentialsHandle
            // to avoid racing with concurrent sessions on the same context.
            if (_sessionCredentialsHandle is not null)
            {
                return;
            }

            if (_context!.CredentialsHandle is not null)
            {
                return;
            }

            // Multiple sessions on the same TlsContext can call EnsureCredentialsAcquired
            // concurrently and each see CredentialsHandle == null. Atomically install
            // ours; if another session beat us to it, dispose the loser to avoid a leak.
            // Non-Windows PALs return null here (OpenSSL has no cred handle concept); the
            // CompareExchange is a no-op in that case.
            SafeFreeCredentials? acquired = SslStreamPal.AcquireCredentialsHandle(_options, false);
            if (System.Threading.Interlocked.CompareExchange(ref _context!.CredentialsHandle, acquired, null) is not null)
            {
                acquired?.Dispose();
            }
        }

        // 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<byte> data)
        {
            if (data.IsEmpty)
            {
                return;
            }

            ProtocolToken token = default;
            token.RentBuffer = true;
            try
            {
                if (_context!.IsServer)
                {
                    token = SslStreamPal.AcceptSecurityContext(
                        ref ActiveCredentialsRef(),
                        ref _securityContext,
                        data,
                        out _,
                        _options);
                }
                else
                {
                    string hostName = TargetHostNameHelper.NormalizeHostName(_options.TargetHost);
                    token = SslStreamPal.InitializeSecurityContext(
                        ref ActiveCredentialsRef(),
                        ref _securityContext,
                        hostName,
                        data,
                        out _,
                        _options);
                }

                if (token.Size > 0)
                {
                    Debug.Assert(token.Payload != null);
                    AppendPending(new ReadOnlySpan<byte>(token.Payload, 0, token.Size));
                }
            }
            finally
            {
                token.ReleasePayload();
            }
        }

        // ── Socket-bound I/O ─────────────────────────────────────────────
        //
        // These methods are only valid when the session was created via
        // Create(TlsContext, SafeSocketHandle). They drive ciphertext on the
        // bound non-blocking socket and translate WouldBlock into WantRead/
        // WantWrite back to the caller so a select/epoll/IOCP-like loop can
        // schedule the next attempt.

        private const int SocketScratchSize = MaxRecordPlaintext + 256;

        private void ThrowIfNotSocketBound()
        {
            if (_socketHandle is null)
            {
                throw new InvalidOperationException(SR.net_tlssession_not_socket_bound);
            }
        }

        // Drains any TLS bytes that we previously failed to fully send into the
        // socket. Returns true if pending output is now empty, false if the
        // socket would block (WantWrite should be surfaced).
        private bool TryDrainPendingToSocket(out SocketError lastError)
        {
            lastError = SocketError.Success;
            while (_pendingBuffer.ActiveLength > 0)
            {
                int sent = _socket!.Send(
                    _pendingBuffer.ActiveReadOnlySpan,
                    SocketFlags.None,
                    out SocketError err);
                lastError = err;
                if (sent > 0)
                {
                    _pendingBuffer.Discard(sent);
                    if (_pendingBuffer.ActiveLength == 0)
                    {
                        return true;
                    }
                    continue;
                }
                return false;
            }
            return true;
        }

        private protected TlsOperationStatus HandshakeSocketCore()
        {
            ThrowIfDisposed();
            ThrowIfContextNotSet();
            ThrowIfNotSocketBound();

            if (_isHandshakeComplete && !_externalValidationPending && !_externalValidationResolved)
            {
                return TlsOperationStatus.Complete;
            }

            TlsOperationStatus? fast = null;
            TryFastHandshake(ref fast);
            if (fast.HasValue)
            {
                return fast.Value;
            }

            TryPeekClientHello(ref fast);
            if (fast.HasValue)
            {
                return fast.Value;
            }

            _socketInBuffer.EnsureAvailableSpace(SocketScratchSize);
            byte[] scratch = ArrayPool<byte>.Shared.Rent(SocketScratchSize);
            try
            {
                while (true)
                {
                    if (_pendingBuffer.ActiveLength > 0)
                    {
                        if (!TryDrainPendingToSocket(out SocketError drainErr))
                        {
                            if (drainErr == SocketError.WouldBlock)
                            {
                                return TlsOperationStatus.DestinationTooSmall;
                            }
                            throw new SocketException((int)drainErr);
                        }
                    }

                    TlsOperationStatus status = HandshakeBufferedCore(
                        _socketInBuffer.ActiveReadOnlySpan,
                        scratch,
                        out int consumed,
                        out int produced);

                    if (consumed > 0)
                    {
                        _socketInBuffer.Discard(consumed);
                    }

                    if (produced > 0)
                    {
                        int offset = 0;
                        while (offset < produced)
                        {
                            int sent = _socket!.Send(
                                new ReadOnlySpan<byte>(scratch, offset, produced - offset),
                                SocketFlags.None,
                                out SocketError sendErr);
                            if (sent > 0)
                            {
                                offset += sent;
                                continue;
                            }
                            if (sendErr == SocketError.WouldBlock)
                            {
                                // Stash the unsent tail so the next call resumes the drain.
                                AppendPending(new ReadOnlySpan<byte>(scratch, offset, produced - offset));
                                return TlsOperationStatus.DestinationTooSmall;
                            }
                            throw new SocketException((int)sendErr);
                        }
                    }

                    switch (status)
                    {
                        case TlsOperationStatus.Complete:
                            return TlsOperationStatus.Complete;

                        case TlsOperationStatus.NeedMoreData:
                            // Should not happen with conservative scratch sizing, but guard.
                            _socketInBuffer.EnsureAvailableSpace(1);
                            int received = _socket!.Receive(
                                _socketInBuffer.AvailableSpan,
                                SocketFlags.None,
                                out SocketError recvErr);
                            if (received > 0)
                            {
                                _socketInBuffer.Commit(received);
                                continue;
                            }
                            if (recvErr == SocketError.WouldBlock)
                            {
                                return TlsOperationStatus.NeedMoreData;
                            }
                            if (received == 0)
                            {
                                return TlsOperationStatus.Closed;
                            }
                            throw new SocketException((int)recvErr);

                        case TlsOperationStatus.DestinationTooSmall:
                            // Output is staged; loop drains it on next iteration.
                            continue;

                        default:
                            return status;
                    }
                }
            }
            finally
            {
                ArrayPool<byte>.Shared.Return(scratch);
            }
        }

        private protected TlsOperationStatus ReadSocketCore(Span<byte> buffer, out int bytesRead)
        {
            ThrowIfDisposed();
            ThrowIfNotSocketBound();
            bytesRead = 0;

            if (!_isHandshakeComplete)
            {
                throw new InvalidOperationException(SR.net_tlssession_handshake_not_complete);
            }

            TlsOperationStatus? fast = null;
            TryFastRead(buffer, ref bytesRead, ref fast);
            if (fast.HasValue)
            {
                return fast.Value;
            }

            _socketInBuffer.EnsureAvailableSpace(SocketScratchSize);

            while (true)
            {
                if (_socketInBuffer.ActiveLength > 0)
                {
                    TlsOperationStatus status = ReadBufferedCore(
                        _socketInBuffer.ActiveReadOnlySpan,
                        buffer,
                        out int consumed,
                        out int produced);

                    if (consumed > 0)
                    {
                        _socketInBuffer.Discard(consumed);
                    }

                    bytesRead = produced;

                    if (status == TlsOperationStatus.Complete && produced > 0)
                    {
                        return TlsOperationStatus.Complete;
                    }
                    if (status == TlsOperationStatus.Closed)
                    {
                        return TlsOperationStatus.Closed;
                    }
                    if (status == TlsOperationStatus.Complete && produced == 0)
                    {
                        // Post-handshake message consumed; loop to try more.
                        continue;
                    }
                    if (status != TlsOperationStatus.NeedMoreData)
                    {
                        return status;
                    }
                    // WantRead: fall through to socket recv.
                }

                _socketInBuffer.EnsureAvailableSpace(1);
                int received = _socket!.Receive(
                    _socketInBuffer.AvailableSpan,
                    SocketFlags.None,
                    out SocketError recvErr);
                if (received > 0)
                {
                    _socketInBuffer.Commit(received);
                    continue;
                }
                if (recvErr == SocketError.WouldBlock)
                {
                    return TlsOperationStatus.NeedMoreData;
                }
                if (received == 0)
                {
                    return TlsOperationStatus.Closed;
                }
                throw new SocketException((int)recvErr);
            }
        }

        private protected TlsOperationStatus WriteSocketCore(ReadOnlySpan<byte> buffer, out int bytesWritten)
        {
            ThrowIfDisposed();
            ThrowIfNotSocketBound();
            bytesWritten = 0;

            if (!_isHandshakeComplete)
            {
                throw new InvalidOperationException(SR.net_tlssession_handshake_not_complete);
            }

            TlsOperationStatus? fast = null;
            TryFastWrite(buffer, ref bytesWritten, ref fast);
            if (fast.HasValue)
            {
                return fast.Value;
            }

            // Drain any previously stashed ciphertext first.
            if (_pendingBuffer.ActiveLength > 0)
            {
                if (!TryDrainPendingToSocket(out SocketError drainErr))
                {
                    if (drainErr == SocketError.WouldBlock)
                    {
                        return TlsOperationStatus.DestinationTooSmall;
                    }
                    throw new SocketException((int)drainErr);
                }
            }

            if (buffer.IsEmpty)
            {
                return TlsOperationStatus.Complete;
            }

            byte[] scratch = ArrayPool<byte>.Shared.Rent(SocketScratchSize);
            try
            {
                int totalConsumed = 0;
                while (totalConsumed < buffer.Length)
                {
                    TlsOperationStatus encStatus = WriteBufferedCore(
                        buffer.Slice(totalConsumed),
                        scratch,
                        out int consumed,
                        out int produced);

                    totalConsumed += consumed;

                    if (produced > 0)
                    {
                        int offset = 0;
                        while (offset < produced)
                        {
                            int sent = _socket!.Send(
                                new ReadOnlySpan<byte>(scratch, offset, produced - offset),
                                SocketFlags.None,
                                out SocketError sendErr);
                            if (sent > 0)
                            {
                                offset += sent;
                                continue;
                            }
                            if (sendErr == SocketError.WouldBlock)
                            {
                                AppendPending(new ReadOnlySpan<byte>(scratch, offset, produced - offset));
                                bytesWritten = totalConsumed;
                                return TlsOperationStatus.DestinationTooSmall;
                            }
                            throw new SocketException((int)sendErr);
                        }
                    }

                    if (encStatus == TlsOperationStatus.DestinationTooSmall)
                    {
                        // Pending output owed; resume next call.
                        bytesWritten = totalConsumed;
                        return TlsOperationStatus.DestinationTooSmall;
                    }
                    if (encStatus != TlsOperationStatus.Complete)
                    {
                        bytesWritten = totalConsumed;
                        return encStatus;
                    }
                    if (consumed == 0)
                    {
                        // Nothing more to do (shouldn't happen with non-empty buffer).
                        break;
                    }
                }

                bytesWritten = totalConsumed;
                return TlsOperationStatus.Complete;
            }
            finally
            {
                ArrayPool<byte>.Shared.Return(scratch);
            }
        }

        // Simple driver that runs a buffered "output-only" op (Shutdown /
        // RequestClientCertificate) and drains its staged ciphertext to the socket.
        private TlsOperationStatus DriveBufferedOpOverSocket(Func<Span<byte>, (TlsOperationStatus status, int written)> op)
        {
            ThrowIfDisposed();
            ThrowIfNotSocketBound();

            // Drain any leftover pending output before staging new bytes.
            if (_pendingBuffer.ActiveLength > 0)
            {
                if (!TryDrainPendingToSocket(out SocketError leftoverErr))
                {
                    if (leftoverErr == SocketError.WouldBlock)
                    {
                        return TlsOperationStatus.DestinationTooSmall;
                    }
                    throw new SocketException((int)leftoverErr);
                }
            }

            byte[] scratch = ArrayPool<byte>.Shared.Rent(SocketScratchSize);
            try
            {
                (TlsOperationStatus status, int written) = op(scratch);
                if (written > 0)
                {
                    int offset = 0;
                    while (offset < written)
                    {
                        int sent = _socket!.Send(
                            new ReadOnlySpan<byte>(scratch, offset, written - offset),
                            SocketFlags.None,
                            out SocketError sendErr);
                        if (sent > 0)
                        {
                            offset += sent;
                            continue;
                        }
                        if (sendErr == SocketError.WouldBlock)
                        {
                            AppendPending(new ReadOnlySpan<byte>(scratch, offset, written - offset));
                            return TlsOperationStatus.DestinationTooSmall;
                        }
                        throw new SocketException((int)sendErr);
                    }
                }
                return status;
            }
            finally
            {
                ArrayPool<byte>.Shared.Return(scratch);
            }
        }

        private protected TlsOperationStatus ShutdownSocketCore()
            => DriveBufferedOpOverSocket(dest =>
            {
                TlsOperationStatus s = ShutdownBufferedCore(dest, out int w);
                return (s, w);
            });

        private protected TlsOperationStatus RequestClientCertificateSocketCore()
            => DriveBufferedOpOverSocket(dest =>
            {
                TlsOperationStatus s = RequestClientCertificateBufferedCore(dest, out int w);
                return (s, w);
            });

        // Platform hooks. Implemented by the OpenSSL partial (TlsSession.OpenSsl.cs)
        // to bind the socket fd directly to the SSL object and drive ciphertext
        // through OpenSSL. On Windows (SChannel) these are no-ops and the buffered
        // ProcessHandshake/Encrypt/Decrypt path above is used unchanged.
        partial void EnableNativeSocketBinding(SafeSocketHandle socket, ref bool nativeBindingEnabled);
        partial void TryFastHandshake(ref TlsOperationStatus? result);
        partial void TryPeekClientHello(ref TlsOperationStatus? result);
        partial void TryFastRead(Span<byte> buffer, ref int bytesRead, ref TlsOperationStatus? result);
        partial void TryFastWrite(ReadOnlySpan<byte> buffer, ref int bytesWritten, ref TlsOperationStatus? result);

        // Fires at the end of SetContext. Platforms with a deferred-server
        // fast path (OpenSSL socket-bound sessions) use this hook to activate
        // native binding now that server options are known.
        partial void OnServerContextSet();

        // Fires from Dispose so the OpenSSL partial can release the peek BIO if the
        // session is disposed before its ownership is transferred to an SSL* handle.
        partial void OnDispose();

        // Fires from GetClientHelloBytes so the OpenSSL partial can return a span
        // over the socket-replay BIO's retained peek buffer. No-op on the buffered
        // path; the getter falls back to the managed byte[] copy.
        partial void TryGetNativeClientHelloBytes(ref ReadOnlySpan<byte> bytes);

        public void Dispose()
        {
            if (_disposed)
            {
                return;
            }
            _disposed = true;

            DisposeExternalRemoteCertificates();
            _externalPendingCert?.Dispose();
            _externalPendingCert = null;

            _securityContext?.Dispose();
            _securityContext = null;

            // Disposes the underlying SafeSocketHandle as well (ownership transferred at Create).
            if (_socket is not null)
            {
                _socket.Dispose();
                _socket = null;
            }
            else
            {
                _socketHandle?.Dispose();
            }
            _socketHandle = null;

            if (_ownsOptions)
            {
                _options.Dispose();
            }

            _pendingBuffer.Dispose();
            if (_decryptScratch != null)
            {
                ArrayPool<byte>.Shared.Return(_decryptScratch);
                _decryptScratch = null;
            }
            _socketInBuffer.Dispose();

            // Release the session-local credentials handle acquired by
            // SetContext / SetClientCertificateContext. The shared handle on
            // _context is owned by TlsContext and released with it.
            _sessionCredentialsHandle?.Dispose();
            _sessionCredentialsHandle = null;

            // Release the session-owned CertificateContext (only true when we built
            // one via the server-cert-selector path). Caller-provided contexts and the
            // template context inherited from TlsContext are not disposed here.
            if (_ownsSessionCertificateContext && _sessionCertificateContext is not null)
            {
                _sessionCertificateContext.ReleaseResources();
            }
            _sessionCertificateContext = null;
            _ownsSessionCertificateContext = false;

            OnDispose();
        }
    }
}