diff --git a/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.OpenSsl.cs b/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.OpenSsl.cs index a7fa684d3ceda1..cd0c8a847c50ad 100644 --- a/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.OpenSsl.cs +++ b/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.OpenSsl.cs @@ -34,6 +34,15 @@ internal static partial class OpenSsl private const SslProtocols FakeAlpnSslProtocol = (SslProtocols)1; // used to distinguish server sessions with ALPN private static readonly Lazy s_defaultSigAlgs = new(GetDefaultSignatureAlgorithms); +#if DEBUG + // Test-only knob: when DOTNET_OPENSSL_FORCE_BIO_SPILL=1 is set, the managed-span + // BIO is given a zero-length write window, which forces every byte SSL emits + // to take the spill (heap) path inside the BIO. Reading the environment variable + // once is safe because the value never changes during the lifetime of the process. + private static readonly bool s_forceBioSpill = + Environment.GetEnvironmentVariable("DOTNET_OPENSSL_FORCE_BIO_SPILL") == "1"; +#endif + private sealed class SafeSslContextCache : SafeHandleCache { } private static readonly SafeSslContextCache s_sslContexts = new(); @@ -666,21 +675,50 @@ internal static SecurityStatusPal SslRenegotiate(SafeSslHandle sslContext, out b return new SecurityStatusPal(SecurityStatusPalErrorCode.OK); } - internal static SecurityStatusPalErrorCode DoSslHandshake(SafeSslHandle context, ReadOnlySpan input, ref ProtocolToken token) + internal static unsafe SecurityStatusPalErrorCode DoSslHandshake(SafeSslHandle context, ReadOnlySpan input, out int consumed, ref ProtocolToken token) { token.Size = 0; + consumed = 0; Exception? handshakeException = null; - if (input.Length > 0) + // Drain any bytes accumulated in the OutputBio's spill from a prior call + // (e.g. SSL_read emitting alerts before this handshake step). + DrainOutputBioSpill(context, ref token); + + // Reserve a reasonable initial window in the outgoing token; the spill buffer + // catches anything that doesn't fit. + const int InitialHandshakeWindow = 4096; + token.EnsureAvailableSpace(InitialHandshakeWindow); + + int retVal; + int writtenToWindow; + int spillLen; + Ssl.SslErrorCode errorCode; + + Span outputSpan = token.AvailableSpan; +#if DEBUG + if (s_forceBioSpill) { - if (Ssl.BioWrite(context.InputBio!, ref MemoryMarshal.GetReference(input), input.Length) != input.Length) - { - // Make sure we clear out the error that is stored in the queue - throw Crypto.CreateOpenSslCryptographicException(); - } + outputSpan = default; } +#endif + fixed (byte* inputPtr = input) + fixed (byte* outputPtr = outputSpan) + { + retVal = Ssl.SslHandshake( + context, + inputPtr, + input.Length, + out consumed, + outputPtr, + outputSpan.Length, + out writtenToWindow, + out spillLen, + out errorCode); + } + + token.Size += writtenToWindow; - int retVal = Ssl.SslDoHandshake(context, out Ssl.SslErrorCode errorCode); if (retVal != 1) { if (errorCode == Ssl.SslErrorCode.SSL_ERROR_WANT_X509_LOOKUP) @@ -706,31 +744,17 @@ internal static SecurityStatusPalErrorCode DoSslHandshake(SafeSslHandle context, } } - int sendCount = Crypto.BioCtrlPending(context.OutputBio!); - if (sendCount > 0) + if (spillLen > 0) { - token.EnsureAvailableSpace(sendCount); - try - { - sendCount = BioRead(context.OutputBio!, token.AvailableSpan, sendCount); - } - catch (Exception) when (handshakeException != null) + token.EnsureAvailableSpace(spillLen); + Span spillDst = token.AvailableSpan; + fixed (byte* spillPtr = spillDst) { - // If we already have handshake exception, ignore any exception from BioRead(). - } - finally - { - if (sendCount <= 0) - { - // Make sure we clear out the error that is stored in the queue - Crypto.ErrClearError(); - sendCount = 0; - } + int drained = Ssl.BioDrainSpill(context.OutputBio!, spillPtr, spillDst.Length); + token.Size += drained; } } - token.Size = sendCount; - if (handshakeException != null) { ExceptionDispatchInfo.Throw(handshakeException); @@ -755,13 +779,51 @@ internal static SecurityStatusPalErrorCode DoSslHandshake(SafeSslHandle context, return stateOk ? SecurityStatusPalErrorCode.OK : SecurityStatusPalErrorCode.ContinueNeeded; } - internal static Ssl.SslErrorCode Encrypt(SafeSslHandle context, ReadOnlySpan input, ref ProtocolToken outToken) + internal static unsafe Ssl.SslErrorCode Encrypt(SafeSslHandle context, ReadOnlySpan input, ref ProtocolToken outToken) { - int retVal = Ssl.SslWrite(context, ref MemoryMarshal.GetReference(input), input.Length, out Ssl.SslErrorCode errorCode); + // Drain any bytes that the OutputBio may have accumulated outside of an explicit + // write window (e.g. from a prior SSL_read that emitted alerts / KeyUpdate / etc.). + DrainOutputBioSpill(context, ref outToken); + + // Preserve any bytes already in outToken (including those just drained from a prior SSL_read's + // alerts / KeyUpdate output). On error we restore Size to this snapshot so those bytes are + // still sent rather than overwritten with the partial output of a failed SSL_write. + int preWriteSize = outToken.Size; + + // Worst-case TLS output for the user's plaintext. + int upperBound = ComputeMaxTlsOutput(input.Length); + outToken.EnsureAvailableSpace(upperBound); + + int retVal; + int writtenToWindow; + int spillLen; + Ssl.SslErrorCode errorCode; + + Span windowSpan = outToken.AvailableSpan; +#if DEBUG + if (s_forceBioSpill) + { + windowSpan = default; + } +#endif + fixed (byte* plaintextPtr = input) + fixed (byte* windowPtr = windowSpan) + { + retVal = Ssl.SslEncrypt( + context, + plaintextPtr, + input.Length, + windowPtr, + windowSpan.Length, + out writtenToWindow, + out spillLen, + out errorCode); + } if (retVal != input.Length) { - outToken.Size = 0; + // Drop any partial output written by the failed SSL_write but keep the drained spill bytes. + outToken.Size = preWriteSize; switch (errorCode) { // indicate end-of-file @@ -772,35 +834,97 @@ internal static Ssl.SslErrorCode Encrypt(SafeSslHandle context, ReadOnlySpan 0) + { + outToken.EnsureAvailableSpace(spillLen); + Span spillDst = outToken.AvailableSpan; + fixed (byte* spillPtr = spillDst) { - outToken.Size = retVal; + int drained = Ssl.BioDrainSpill(context.OutputBio!, spillPtr, spillDst.Length); + outToken.Size += drained; } } return errorCode; } - internal static int Decrypt(SafeSslHandle context, Span buffer, out Ssl.SslErrorCode errorCode) + private static int ComputeMaxTlsOutput(int inputLength) { - BioWrite(context.InputBio!, buffer); + // TLS 1.3 record max plaintext = 16384 bytes. Per-record overhead is bounded by + // OpenSSL's SSL3_RT_MAX_ENCRYPTED_OVERHEAD (256 bytes, covering record header, AEAD + // tag, optional MAC, padding, and the inner content-type byte for TLS 1.3). + // Always add slack for at least one record's overhead even when inputLength == 0, + // since SSL_write of an empty buffer can still emit handshake/alert bytes. + // + // No overflow check is needed: SslStream chunks user writes to MaxDataSize before + // calling EncryptMessage (see WriteAsyncChunked in SslStream.IO.cs), and on Unix + // MaxDataSize is at most StreamSizes.Default.MaximumMessage = 32 * 1024. The + // resulting upper bound (~33 KiB) is several orders of magnitude below int.MaxValue. + // The assert below guards against accidentally breaking that invariant in the future. + const int MaxExpectedInput = 32 * 1024; + Debug.Assert( + (uint)inputLength <= MaxExpectedInput, + $"ComputeMaxTlsOutput: inputLength {inputLength} exceeds expected upper bound {MaxExpectedInput}; SslStream chunking invariant broken."); + const int MaxRecordOverhead = 256; + int records = (inputLength >> 14) + 2; + return inputLength + (records * MaxRecordOverhead); + } - int retVal = Ssl.SslRead(context, ref MemoryMarshal.GetReference(buffer), buffer.Length, out errorCode); - if (retVal > 0) + private static unsafe void DrainOutputBioSpill(SafeSslHandle context, ref ProtocolToken outToken) + { + Ssl.BioGetWriteResult(context.OutputBio!, out _, out int spillLen); + if (spillLen <= 0) + { + return; + } + + outToken.EnsureAvailableSpace(spillLen); + Span dst = outToken.AvailableSpan; + fixed (byte* dstPtr = dst) + { + int drained = Ssl.BioDrainSpill(context.OutputBio!, dstPtr, dst.Length); + outToken.Size += drained; + } + } + + internal static unsafe int Decrypt( + SafeSslHandle context, + Span input, + Span output, + out int leftoverOffset, + out int leftoverLength, + out Ssl.SslErrorCode errorCode) + { + int retVal; + int consumed; + fixed (byte* inputPtr = input) + fixed (byte* outputPtr = output) + { + retVal = Ssl.SslDecrypt( + context, + inputPtr, + input.Length, + out consumed, + outputPtr, + output.Length, + out leftoverOffset, + out leftoverLength, + out errorCode); + } + if (retVal + leftoverLength > 0) { + // The managed callers always pass exactly one full TLS frame (sized via + // EnsureFullTlsFrameAsync). OpenSSL's SSL_read consumes whole records on + // success, so on any successful decrypt the entire frame is consumed - the + // input span has no residual ciphertext to forward and `consumed` is + // not plumbed through the managed surface. + Debug.Assert(consumed == input.Length, "Expected all input to be consumed."); return retVal; } diff --git a/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.Ssl.cs b/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.Ssl.cs index af4eb0f78a8b01..c2bb91f7113794 100644 --- a/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.Ssl.cs +++ b/src/libraries/Common/src/Interop/Unix/System.Security.Cryptography.Native/Interop.Ssl.cs @@ -117,8 +117,40 @@ internal static ushort[] GetDefaultSignatureAlgorithms() [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslSetBio")] internal static partial void SslSetBio(SafeSslHandle ssl, SafeBioHandle rbio, SafeBioHandle wbio); - [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslDoHandshake", SetLastError = true)] - internal static partial int SslDoHandshake(SafeSslHandle ssl, out SslErrorCode error); + [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslHandshake", SetLastError = true)] + internal static unsafe partial int SslHandshake( + SafeSslHandle ssl, + byte* inputPtr, + int inputLen, + out int consumed, + byte* outputPtr, + int outputCap, + out int outputWritten, + out int outputPending, + out SslErrorCode errorCode); + + [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslEncrypt", SetLastError = true)] + internal static unsafe partial int SslEncrypt( + SafeSslHandle ssl, + byte* plaintextPtr, + int plaintextLen, + byte* outputPtr, + int outputCap, + out int outputWritten, + out int outputPending, + out SslErrorCode errorCode); + + [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslDecrypt", SetLastError = true)] + internal static unsafe partial int SslDecrypt( + SafeSslHandle ssl, + byte* inputPtr, + int inputLen, + out int consumed, + byte* outputPtr, + int outputCap, + out int leftoverOffset, + out int leftoverLength, + out SslErrorCode errorCode); [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_IsSslStateOK")] [return: MarshalAs(UnmanagedType.Bool)] @@ -131,6 +163,15 @@ internal static ushort[] GetDefaultSignatureAlgorithms() [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_BioWrite")] internal static partial int BioWrite(SafeBioHandle b, ref byte data, int len); + [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_BioNewManagedSpan")] + internal static partial SafeBioHandle BioNewManagedSpan(); + + [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_BioGetWriteResult")] + internal static partial void BioGetWriteResult(SafeBioHandle bio, out int writtenToWindow, out int spillLen); + + [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_BioDrainSpill")] + internal static unsafe partial int BioDrainSpill(SafeBioHandle bio, byte* dst, int dstLen); + [LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslGetPeerCertificate")] internal static partial IntPtr SslGetPeerCertificate(SafeSslHandle ssl); @@ -437,8 +478,8 @@ internal void MarkHandshakeCompleted() public static SafeSslHandle Create(SafeSslContextHandle context, SslAuthenticationOptions options) { - SafeBioHandle readBio = Interop.Crypto.CreateMemoryBio(); - SafeBioHandle writeBio = Interop.Crypto.CreateMemoryBio(); + SafeBioHandle readBio = Interop.Ssl.BioNewManagedSpan(); + SafeBioHandle writeBio = Interop.Ssl.BioNewManagedSpan(); SafeSslHandle handle = Interop.Ssl.SslCreate(context); if (readBio.IsInvalid || writeBio.IsInvalid || handle.IsInvalid) { diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs index a8faaae4fa9cbb..98016da05b0859 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs @@ -85,24 +85,6 @@ private void CloseInternal() } } - private ProtocolToken EncryptData(ReadOnlyMemory buffer) - { - ThrowIfExceptionalOrNotAuthenticated(); - - lock (_handshakeLock) - { - if (_handshakeWaiter != null) - { - ProtocolToken token = default; - // avoid waiting under lock. - token.Status = new SecurityStatusPal(SecurityStatusPalErrorCode.TryAgain); - return token; - } - - return Encrypt(buffer); - } - } - // // This method assumes that a SSPI context is already in a good shape. // For example it is either a fresh context or already authenticated context that needs renegotiation. @@ -845,46 +827,6 @@ private async ValueTask EnsureFullTlsFrameAsync(CancellationTok return frameSize; } - private SecurityStatusPal DecryptData(int frameSize) - { - SecurityStatusPal status; - - lock (_handshakeLock) - { - ThrowIfExceptionalOrNotAuthenticated(); - - // Decrypt will decrypt in-place and modify these to point to the actual decrypted data, which may be smaller. - status = Decrypt(_buffer.EncryptedSpanSliced(frameSize), out int decryptedOffset, out int decryptedCount); - _buffer.OnDecrypted(decryptedOffset, decryptedCount, frameSize); - - if (status.ErrorCode == SecurityStatusPalErrorCode.Renegotiate) - { - // The status indicates that peer wants to renegotiate. (Windows only) - // In practice, there can be some other reasons too - like TLS1.3 session creation - // of alert handling. We need to pass the data to lsass and it is not safe to do parallel - // write any more as that can change TLS state and the EncryptData() can fail in strange ways. - - // To handle this we call DecryptData() under lock and we create TCS waiter. - // EncryptData() checks that under same lock and if it exist it will not call low-level crypto. - // Instead it will wait synchronously or asynchronously and it will try again after the wait. - // The result will be set when ReplyOnReAuthenticationAsync() is finished e.g. lsass business is over. - // If that happen before EncryptData() runs, _handshakeWaiter will be set to null - // and EncryptData() will work normally e.g. no waiting, just exclusion with DecryptData() - - if (_sslAuthenticationOptions.AllowRenegotiation || SslProtocol == SslProtocols.Tls13 || _nestedAuth != NestedState.StreamNotInUse) - { - // create TCS only if we plan to proceed. If not, we will throw later outside of the lock. - // Tls1.3 does not have renegotiation. However on Windows this error code is used - // for session management e.g. anything lsass needs to see. - // We also allow it when explicitly requested using RenegotiateAsync(). - _handshakeWaiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - } - } - - return status; - } - [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] private async ValueTask ReadAsyncInternal(Memory buffer, CancellationToken cancellationToken) where TIOAdapter : IReadWriteAdapter @@ -944,7 +886,13 @@ private async ValueTask ReadAsyncInternal(Memory buffer, break; } - SecurityStatusPal status = DecryptData(payloadBytes); + // Pass the user buffer to DecryptData so PALs that support it can decrypt directly + // into the destination, avoiding a CopyDecryptedData memcpy. When the renegotiate + // path is in flight we suppress the direct path by passing an empty span - the PAL + // will fall back to in-place decrypt and the existing CopyDecryptedData path runs. + Span destination = _handshakeWaiter == null ? buffer.Span : default; + SecurityStatusPal status = DecryptData(payloadBytes, destination, out int directWritten); + if (status.ErrorCode != SecurityStatusPalErrorCode.OK) { byte[]? extraBuffer = null; @@ -979,7 +927,16 @@ private async ValueTask ReadAsyncInternal(Memory buffer, } } - if (_buffer.DecryptedLength > 0) + if (directWritten > 0) + { + processedLength += directWritten; + buffer = buffer.Slice(directWritten); + if (buffer.IsEmpty) + { + break; + } + } + else if (_buffer.DecryptedLength > 0) { // This will either copy data from rented buffer or adjust final buffer as needed. // In both cases _decryptedBytesOffset and _decryptedBytesCount will be updated as needed. diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs index 51f673f2111edc..dff7c776cb5451 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs @@ -11,6 +11,7 @@ using System.Security.Authentication.ExtendedProtection; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; namespace System.Net.Security { @@ -970,30 +971,100 @@ internal void ProcessHandshakeSuccess() #endif } - internal ProtocolToken Encrypt(ReadOnlyMemory buffer) + private ProtocolToken EncryptData(ReadOnlyMemory buffer) { - if (NetEventSource.Log.IsEnabled()) NetEventSource.DumpBuffer(this, buffer.Span); + ThrowIfExceptionalOrNotAuthenticated(); - ProtocolToken token = SslStreamPal.EncryptMessage( - _securityContext!, - buffer, - _headerSize, - _trailerSize); - - if (token.Status.ErrorCode != SecurityStatusPalErrorCode.OK) + lock (_handshakeLock) { - if (NetEventSource.Log.IsEnabled()) NetEventSource.Error(this, $"ERROR {token.Status}"); - } + if (_handshakeWaiter != null) + { + ProtocolToken waitToken = default; + // avoid waiting under lock. + waitToken.Status = new SecurityStatusPal(SecurityStatusPalErrorCode.TryAgain); + return waitToken; + } - return token; + if (NetEventSource.Log.IsEnabled()) NetEventSource.DumpBuffer(this, buffer.Span); + + ProtocolToken token = SslStreamPal.EncryptMessage( + _securityContext!, + buffer, + _headerSize, + _trailerSize); + + if (token.Status.ErrorCode != SecurityStatusPalErrorCode.OK) + { + if (NetEventSource.Log.IsEnabled()) NetEventSource.Error(this, $"ERROR {token.Status}"); + } + + return token; + } } - internal SecurityStatusPal Decrypt(Span buffer, out int outputOffset, out int outputCount) + // On some platforms, the platform APIs decrypt in-place via single + // call (Schannel), while others have separate write-ciphertext + + // read-plaintext primitives. To allow the most efficient thing (copying + // plaintext straight to the `destination` buffer provided by the + // SslStream caller) on platforms that support it, the contract of this + // method is as follows: + // - After the call, first `bytesWritten` bytes of `destination` contain decrypted plaintext + // - Rest of the decrypted plaintext, if any, is stored in `_buffer.DecryptedSpan`. + private SecurityStatusPal DecryptData(int frameSize, Span destination, out int bytesWritten) { - SecurityStatusPal status = SslStreamPal.DecryptMessage(_securityContext!, buffer, out outputOffset, out outputCount); - if (NetEventSource.Log.IsEnabled() && status.ErrorCode == SecurityStatusPalErrorCode.OK) + SecurityStatusPal status; + + lock (_handshakeLock) { - NetEventSource.DumpBuffer(this, buffer.Slice(outputOffset, outputCount)); + ThrowIfExceptionalOrNotAuthenticated(); + + status = SslStreamPal.DecryptMessage( + _securityContext!, + _buffer.EncryptedSpanSliced(frameSize), + destination, + out bytesWritten, + out int leftoverOffset, + out int leftoverLength); + + _buffer.OnDecrypted(leftoverOffset, leftoverLength, frameSize); + + if (NetEventSource.Log.IsEnabled() && status.ErrorCode == SecurityStatusPalErrorCode.OK) + { + if (bytesWritten > 0) + { + NetEventSource.DumpBuffer(this, destination.Slice(0, bytesWritten)); + } + + if (_buffer.DecryptedSpan.Length > 0) + { + NetEventSource.DumpBuffer(this, _buffer.DecryptedSpan); + } + } + + if (status.ErrorCode == SecurityStatusPalErrorCode.Renegotiate) + { + // The status indicates that the peer or TLS implementation requires additional + // handshake/session processing. In practice, there can be other reasons too, + // like TLS1.3 session creation or alert handling. We need to pass the data to + // the underlying security provider and it is not safe to do parallel write any + // more as that can change TLS state and the EncryptData() can fail in strange ways. + + // To handle this we call DecryptData() under lock and we create TCS waiter. + // EncryptData() checks that under same lock and if it exist it will not call low-level crypto. + // Instead it will wait synchronously or asynchronously and it will try again after the wait. + // The result will be set when ReplyOnReAuthenticationAsync() is finished e.g. lsass business is over. + // If that happen before EncryptData() runs, _handshakeWaiter will be set to null + // and EncryptData() will work normally e.g. no waiting, just exclusion with DecryptData() + + if (_sslAuthenticationOptions.AllowRenegotiation || SslProtocol == SslProtocols.Tls13 || _nestedAuth != NestedState.StreamNotInUse) + { + // create TCS only if we plan to proceed. If not, we will throw later outside of the lock. + // Tls1.3 does not have renegotiation. However on Windows this error code is used + // for session management e.g. anything lsass needs to see. + // We also allow it when explicitly requested using RenegotiateAsync(). + _handshakeWaiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + } } return status; diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Android.cs index 68df93d01217cd..7b33f5a48dca9f 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Android.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Android.cs @@ -111,40 +111,64 @@ public static ProtocolToken EncryptMessage( public static SecurityStatusPal DecryptMessage( SafeDeleteSslContext securityContext, - Span buffer, - out int offset, - out int count) + Span encrypted, + Span destination, + out int bytesWritten, + out int leftoverOffset, + out int leftoverLength) { - offset = 0; - count = 0; + bytesWritten = 0; + leftoverOffset = 0; + leftoverLength = 0; try { SafeSslHandle sslHandle = securityContext.SslContext; - securityContext.Write(buffer); + securityContext.Write(encrypted); - PAL_SSLStreamStatus ret = Interop.AndroidCrypto.SSLStreamRead(sslHandle, buffer, out int read); - if (ret == PAL_SSLStreamStatus.Error) - return new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError); + PAL_SSLStreamStatus ret; - count = read; - - SecurityStatusPalErrorCode statusCode = ret switch + // Opportunistically decrypt directly into the caller-provided destination span + // when one was supplied (saves a memcpy through the SslStream-owned buffer). + // If the first read does not fill the destination, all currently available + // plaintext has been consumed and we report the resulting status as-is. + if (!destination.IsEmpty) { - PAL_SSLStreamStatus.OK => SecurityStatusPalErrorCode.OK, - PAL_SSLStreamStatus.NeedData => SecurityStatusPalErrorCode.OK, - PAL_SSLStreamStatus.Renegotiate => SecurityStatusPalErrorCode.Renegotiate, - PAL_SSLStreamStatus.Closed => SecurityStatusPalErrorCode.ContextExpired, - _ => SecurityStatusPalErrorCode.InternalError - }; + ret = Interop.AndroidCrypto.SSLStreamRead(sslHandle, destination, out int written); + if (ret == PAL_SSLStreamStatus.Error) + return new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError); + + bytesWritten = written; + if (ret != PAL_SSLStreamStatus.OK || written < destination.Length) + { + return new SecurityStatusPal(MapSSLStreamStatus(ret)); + } + } - return new SecurityStatusPal(statusCode); + // Either destination was empty, or the first read filled it; capture any + // remaining plaintext in-place inside the ciphertext span so SslStream can + // pick it up via leftoverOffset/leftoverLength. + ret = Interop.AndroidCrypto.SSLStreamRead(sslHandle, encrypted, out int leftover); + if (ret == PAL_SSLStreamStatus.Error) + return new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError); + + leftoverLength = leftover; + return new SecurityStatusPal(MapSSLStreamStatus(ret)); } catch (Exception e) { return new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError, e); } + + static SecurityStatusPalErrorCode MapSSLStreamStatus(PAL_SSLStreamStatus status) => status switch + { + PAL_SSLStreamStatus.OK => SecurityStatusPalErrorCode.OK, + PAL_SSLStreamStatus.NeedData => SecurityStatusPalErrorCode.OK, + PAL_SSLStreamStatus.Renegotiate => SecurityStatusPalErrorCode.Renegotiate, + PAL_SSLStreamStatus.Closed => SecurityStatusPalErrorCode.ContextExpired, + _ => SecurityStatusPalErrorCode.InternalError, + }; } public static ChannelBinding? QueryContextChannelBinding( diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs index 7aff801dab8260..19cfd42baf353e 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs @@ -205,27 +205,41 @@ public static ProtocolToken EncryptMessage( public static SecurityStatusPal DecryptMessage( SafeDeleteContext securityContext, - Span buffer, - out int offset, - out int count) + Span encrypted, + Span destination, + out int bytesWritten, + out int leftoverOffset, + out int leftoverLength) { + bytesWritten = 0; + leftoverOffset = 0; + leftoverLength = 0; + Debug.Assert(securityContext is SafeDeleteSslContext, "SafeDeleteSslContext expected"); SafeDeleteSslContext sslContext = (SafeDeleteSslContext)securityContext; - offset = 0; - count = 0; - try { SafeSslHandle sslHandle = sslContext.SslContext; - sslContext.Write(buffer); + sslContext.Write(encrypted); + + PAL_TlsIo status; unsafe { - fixed (byte* ptr = buffer) + // Opportunistically decrypt directly into the caller-provided destination span + // when one was supplied (saves a memcpy through the SslStream-owned buffer). + // If the first read does not fill the destination, all currently available + // plaintext has been consumed and we report the resulting status as-is. + if (!destination.IsEmpty) { - PAL_TlsIo status = Interop.AppleCrypto.SslRead(sslHandle, ptr, buffer.Length, out int written); + int written; + fixed (byte* destPtr = destination) + { + status = Interop.AppleCrypto.SslRead(sslHandle, destPtr, destination.Length, out written); + } + if (status < 0) { return new SecurityStatusPal( @@ -233,29 +247,43 @@ public static SecurityStatusPal DecryptMessage( Interop.AppleCrypto.CreateExceptionForOSStatus((int)status)); } - count = written; - offset = 0; + bytesWritten = written; + if (status != PAL_TlsIo.Success || written < destination.Length) + { + return MapTlsIoStatus(status); + } + } - switch (status) + // Either destination was empty, or the first read filled it; capture any + // remaining plaintext in-place inside the ciphertext span so SslStream can + // pick it up via leftoverOffset/leftoverLength. + fixed (byte* ptr = encrypted) + { + status = Interop.AppleCrypto.SslRead(sslHandle, ptr, encrypted.Length, out int leftover); + if (status < 0) { - case PAL_TlsIo.Success: - case PAL_TlsIo.WouldBlock: - return new SecurityStatusPal(SecurityStatusPalErrorCode.OK); - case PAL_TlsIo.ClosedGracefully: - return new SecurityStatusPal(SecurityStatusPalErrorCode.ContextExpired); - case PAL_TlsIo.Renegotiate: - return new SecurityStatusPal(SecurityStatusPalErrorCode.Renegotiate); - default: - Debug.Fail($"Unknown status value: {status}"); - return new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError); + return new SecurityStatusPal( + SecurityStatusPalErrorCode.InternalError, + Interop.AppleCrypto.CreateExceptionForOSStatus((int)status)); } + leftoverLength = leftover; } } + + return MapTlsIoStatus(status); } catch (Exception e) { return new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError, e); } + + static SecurityStatusPal MapTlsIoStatus(PAL_TlsIo status) => status switch + { + PAL_TlsIo.Success or PAL_TlsIo.WouldBlock => new SecurityStatusPal(SecurityStatusPalErrorCode.OK), + PAL_TlsIo.ClosedGracefully => new SecurityStatusPal(SecurityStatusPalErrorCode.ContextExpired), + PAL_TlsIo.Renegotiate => new SecurityStatusPal(SecurityStatusPalErrorCode.Renegotiate), + _ => new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError), + }; } public static ChannelBinding? QueryContextChannelBinding( diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Unix.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Unix.cs index 77c06a6dac022b..cf4c5492dec78b 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Unix.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Unix.cs @@ -79,23 +79,30 @@ public static ProtocolToken EncryptMessage(SafeDeleteSslContext securityContext, return token; } - public static SecurityStatusPal DecryptMessage(SafeDeleteSslContext securityContext, Span buffer, out int offset, out int count) + public static SecurityStatusPal DecryptMessage( + SafeDeleteSslContext securityContext, + Span encrypted, + Span destination, + out int bytesWritten, + out int leftoverOffset, + out int leftoverLength) { - offset = 0; - count = 0; + bytesWritten = 0; + leftoverOffset = 0; + leftoverLength = 0; try { - int resultSize = Interop.OpenSsl.Decrypt((SafeSslHandle)securityContext, buffer, out Interop.Ssl.SslErrorCode errorCode); + bytesWritten = Interop.OpenSsl.Decrypt( + (SafeSslHandle)securityContext, + encrypted, + destination, + out leftoverOffset, + out leftoverLength, + out Interop.Ssl.SslErrorCode errorCode); SecurityStatusPal retVal = MapNativeErrorCode(errorCode); - if (retVal.ErrorCode == SecurityStatusPalErrorCode.OK || - retVal.ErrorCode == SecurityStatusPalErrorCode.Renegotiate) - { - count = resultSize; - } - return retVal; } catch (Exception ex) @@ -186,8 +193,7 @@ private static ProtocolToken HandshakeInternal(ref SafeDeleteSslContext? context context = Interop.OpenSsl.AllocateSslHandle(sslAuthenticationOptions); } - SecurityStatusPalErrorCode errorCode = Interop.OpenSsl.DoSslHandshake((SafeSslHandle)context, inputBuffer, ref token); - consumed = inputBuffer.Length; + SecurityStatusPalErrorCode errorCode = Interop.OpenSsl.DoSslHandshake((SafeSslHandle)context, inputBuffer, out consumed, ref token); if (errorCode == SecurityStatusPalErrorCode.CredentialsNeeded) { @@ -206,14 +212,16 @@ private static ProtocolToken HandshakeInternal(ref SafeDeleteSslContext? context // set the cert and continue TryUpdateClintCertificate(null, context, sslAuthenticationOptions); - errorCode = Interop.OpenSsl.DoSslHandshake((SafeSslHandle)context, ReadOnlySpan.Empty, ref token); + errorCode = Interop.OpenSsl.DoSslHandshake((SafeSslHandle)context, inputBuffer.Slice(consumed), out int c, ref token); + consumed += c; } // sometimes during renegotiation processing message does not yield new output. // That seems to be flaw in OpenSSL state machine and we have workaround to peek it and try it again. if (token.Size == 0 && Interop.Ssl.IsSslRenegotiatePending((SafeSslHandle)context)) { - errorCode = Interop.OpenSsl.DoSslHandshake((SafeSslHandle)context, ReadOnlySpan.Empty, ref token); + errorCode = Interop.OpenSsl.DoSslHandshake((SafeSslHandle)context, inputBuffer.Slice(consumed), out int c, ref token); + consumed += c; } token.Status = new SecurityStatusPal(errorCode); diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs index 0059c83b1f8fb3..ac6d5175556c4d 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs @@ -601,8 +601,17 @@ public static unsafe ProtocolToken EncryptMessage(SafeDeleteSslContext securityC return token; } - public static unsafe SecurityStatusPal DecryptMessage(SafeDeleteSslContext? securityContext, Span buffer, out int offset, out int count) + public static unsafe SecurityStatusPal DecryptMessage( + SafeDeleteSslContext? securityContext, + Span encrypted, + Span destination, + out int bytesWritten, + out int leftoverOffset, + out int leftoverLength) { + // SChannel always decrypts in-place; the caller-provided `destination` is unused. + _ = destination; + bytesWritten = 0; const int NumSecBuffers = 4; // data + empty + empty + empty Span unmanagedBuffers = stackalloc Interop.SspiCli.SecBuffer[NumSecBuffers]; @@ -614,12 +623,12 @@ public static unsafe SecurityStatusPal DecryptMessage(SafeDeleteSslContext? secu emptyBuffer.cbBuffer = 0; } - fixed (byte* bufferPtr = buffer) + fixed (byte* bufferPtr = encrypted) { ref Interop.SspiCli.SecBuffer dataBuffer = ref unmanagedBuffers[0]; dataBuffer.BufferType = SecurityBufferType.SECBUFFER_DATA; dataBuffer.pvBuffer = (IntPtr)bufferPtr; - dataBuffer.cbBuffer = buffer.Length; + dataBuffer.cbBuffer = encrypted.Length; Interop.SspiCli.SecBufferDesc sdcInOut = new Interop.SspiCli.SecBufferDesc(NumSecBuffers) { @@ -629,8 +638,8 @@ public static unsafe SecurityStatusPal DecryptMessage(SafeDeleteSslContext? secu // Decrypt may repopulate the sec buffers, likely with header + data + trailer + empty. // We need to find the data. - count = 0; - offset = 0; + leftoverLength = 0; + leftoverOffset = 0; for (int i = 0; i < NumSecBuffers; i++) { // Successfully decoded data and placed it at the following position in the buffer, @@ -638,12 +647,12 @@ public static unsafe SecurityStatusPal DecryptMessage(SafeDeleteSslContext? secu // or we failed to decode the data, here is the encoded data. || (errorCode != Interop.SECURITY_STATUS.OK && unmanagedBuffers[i].BufferType == SecurityBufferType.SECBUFFER_EXTRA)) { - offset = (int)((byte*)unmanagedBuffers[i].pvBuffer - bufferPtr); - count = unmanagedBuffers[i].cbBuffer; + leftoverOffset = (int)((byte*)unmanagedBuffers[i].pvBuffer - bufferPtr); + leftoverLength = unmanagedBuffers[i].cbBuffer; - // output is ignored on Windows. We always decrypt in place and we set outputOffset to indicate where the data start. - Debug.Assert(offset >= 0 && count >= 0, $"Expected offset and count greater than 0, got {offset} and {count}"); - Debug.Assert(checked(offset + count) <= buffer.Length, $"Expected offset+count <= buffer.Length, got {offset}+{count}>={buffer.Length}"); + // destination is ignored on Windows. We always decrypt in place and we set leftoverOffset to indicate where the data start. + Debug.Assert(leftoverOffset >= 0 && leftoverLength >= 0, $"Expected offset and length greater than or equal to 0, got {leftoverOffset} and {leftoverLength}"); + Debug.Assert(checked(leftoverOffset + leftoverLength) <= encrypted.Length, $"Expected offset+length <= encrypted.Length, got {leftoverOffset}+{leftoverLength}>{encrypted.Length}"); break; } diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamForceSpillTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamForceSpillTests.cs new file mode 100644 index 00000000000000..50093e58d9ea2c --- /dev/null +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamForceSpillTests.cs @@ -0,0 +1,208 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.RemoteExecutor; +using Xunit; + +namespace System.Net.Security.Tests +{ + using Configuration = System.Net.Test.Common.Configuration; + + // Exercises the spill path of the managed-span BIO used on Linux TLS. + // + // When DOTNET_OPENSSL_FORCE_BIO_SPILL=1 is set, the managed Interop.OpenSsl + // helpers pass a zero-length write window to SslHandshake/SslEncrypt so + // every byte written by SSL takes the spill (heap) path inside the BIO. + // These tests run the same SslStream scenarios with that override turned + // on so the spill path gets the same level of functional coverage as the + // fast (window) path. + // + // The knob is compiled out of release builds of System.Net.Security, so + // these tests only meaningfully exercise the spill path against Debug + // builds and are skipped otherwise. + [PlatformSpecific(TestPlatforms.Linux)] + public class SslStreamForceSpillTests + { + public static bool IsSupported => + RemoteExecutor.IsSupported && PlatformDetection.IsDebugLibrary(typeof(SslStream).Assembly); + + private static ProcessStartInfo CreateForceSpillStartInfo() + { + var psi = new ProcessStartInfo(); + psi.Environment["DOTNET_OPENSSL_FORCE_BIO_SPILL"] = "1"; + return psi; + } + + [ConditionalFact(nameof(IsSupported))] + public async Task ForceSpill_PingPong_Succeeds() + { + await RemoteExecutor.Invoke(static async () => + { + (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); + using (clientStream) + using (serverStream) + using (var client = new SslStream(clientStream)) + using (var server = new SslStream(serverStream)) + using (X509Certificate2 certificate = Configuration.Certificates.GetServerCertificate()) + { + SslClientAuthenticationOptions clientOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = delegate { return true; }, + TargetHost = "localhost", + }; + + SslServerAuthenticationOptions serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = certificate, + }; + + await TestConfiguration.WhenAllOrAnyFailedWithTimeout( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)); + + await TestHelper.PingPong(client, server); + } + }, new RemoteInvokeOptions { StartInfo = CreateForceSpillStartInfo() }).DisposeAsync(); + } + + [ConditionalTheory(nameof(IsSupported))] + [InlineData(1)] + [InlineData(64 * 1024)] + [InlineData(256 * 1024)] + [InlineData(1024 * 1024)] + public async Task ForceSpill_LargeTransfer_Succeeds(int payloadSize) + { + await RemoteExecutor.Invoke(static async (payloadSizeString) => + { + int payloadSize = int.Parse(payloadSizeString); + + (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); + using (clientStream) + using (serverStream) + using (var client = new SslStream(clientStream)) + using (var server = new SslStream(serverStream)) + using (X509Certificate2 certificate = Configuration.Certificates.GetServerCertificate()) + { + SslClientAuthenticationOptions clientOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = delegate { return true; }, + TargetHost = "localhost", + }; + + SslServerAuthenticationOptions serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = certificate, + }; + + await TestConfiguration.WhenAllOrAnyFailedWithTimeout( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)); + + byte[] sendBuffer = new byte[payloadSize]; + for (int i = 0; i < sendBuffer.Length; i++) + { + sendBuffer[i] = (byte)(i & 0xFF); + } + + byte[] receiveBuffer = new byte[payloadSize]; + + using var cts = new CancellationTokenSource(TestConfiguration.PassingTestTimeout); + + Task writeTask = client.WriteAsync(sendBuffer, cts.Token).AsTask(); + Task readTask = ReadExactlyAsync(server, receiveBuffer, cts.Token); + + await TestConfiguration.WhenAllOrAnyFailedWithTimeout(writeTask, readTask); + + Assert.Equal(sendBuffer, receiveBuffer); + } + }, payloadSize.ToString(), new RemoteInvokeOptions { StartInfo = CreateForceSpillStartInfo() }).DisposeAsync(); + } + + [ConditionalFact(nameof(IsSupported))] + public async Task ForceSpill_BidirectionalStress_Succeeds() + { + await RemoteExecutor.Invoke(static async () => + { + const int Iterations = 64; + const int PayloadSize = 17 * 1024; // not a record-size multiple, crosses record boundaries + + (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); + using (clientStream) + using (serverStream) + using (var client = new SslStream(clientStream)) + using (var server = new SslStream(serverStream)) + using (X509Certificate2 certificate = Configuration.Certificates.GetServerCertificate()) + { + SslClientAuthenticationOptions clientOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = delegate { return true; }, + TargetHost = "localhost", + }; + + SslServerAuthenticationOptions serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = certificate, + }; + + await TestConfiguration.WhenAllOrAnyFailedWithTimeout( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)); + + byte[] payload = new byte[PayloadSize]; + for (int i = 0; i < payload.Length; i++) + { + payload[i] = (byte)((i * 31) & 0xFF); + } + + using var cts = new CancellationTokenSource(TestConfiguration.PassingTestTimeout); + + Task clientLoop = RunPingPongAsync(client, payload, Iterations, cts.Token); + Task serverLoop = RunEchoAsync(server, payload.Length, Iterations, cts.Token); + + await TestConfiguration.WhenAllOrAnyFailedWithTimeout(clientLoop, serverLoop); + } + + static async Task RunPingPongAsync(SslStream stream, byte[] payload, int iterations, CancellationToken ct) + { + byte[] receive = new byte[payload.Length]; + for (int i = 0; i < iterations; i++) + { + await stream.WriteAsync(payload, ct); + await ReadExactlyAsync(stream, receive, ct); + Assert.Equal(payload, receive); + } + } + + static async Task RunEchoAsync(SslStream stream, int size, int iterations, CancellationToken ct) + { + byte[] buffer = new byte[size]; + for (int i = 0; i < iterations; i++) + { + await ReadExactlyAsync(stream, buffer, ct); + await stream.WriteAsync(buffer, ct); + } + } + }, new RemoteInvokeOptions { StartInfo = CreateForceSpillStartInfo() }).DisposeAsync(); + } + + private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken cancellationToken) + { + int total = 0; + while (total < buffer.Length) + { + int read = await stream.ReadAsync(buffer.Slice(total), cancellationToken); + if (read == 0) + { + throw new EndOfStreamException(); + } + total += read; + } + } + } +} diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj b/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj index 79610468fe7995..1e28799028ecef 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj @@ -124,6 +124,7 @@ + diff --git a/src/native/libs/System.Security.Cryptography.Native/entrypoints.c b/src/native/libs/System.Security.Cryptography.Native/entrypoints.c index 389504b3265e3f..25d119a0c16563 100644 --- a/src/native/libs/System.Security.Cryptography.Native/entrypoints.c +++ b/src/native/libs/System.Security.Cryptography.Native/entrypoints.c @@ -46,8 +46,11 @@ static const Entry s_cryptoNative[] = DllImportEntry(CryptoNative_BigNumToBinary) DllImportEntry(CryptoNative_BioCtrlPending) DllImportEntry(CryptoNative_BioDestroy) + DllImportEntry(CryptoNative_BioDrainSpill) + DllImportEntry(CryptoNative_BioGetWriteResult) DllImportEntry(CryptoNative_BioGets) DllImportEntry(CryptoNative_BioNewFile) + DllImportEntry(CryptoNative_BioNewManagedSpan) DllImportEntry(CryptoNative_BioRead) DllImportEntry(CryptoNative_BioSeek) DllImportEntry(CryptoNative_BioTell) @@ -378,7 +381,9 @@ static const Entry s_cryptoNative[] = DllImportEntry(CryptoNative_SslAddExtraChainCert) DllImportEntry(CryptoNative_SslAddClientCAs) DllImportEntry(CryptoNative_SslDestroy) - DllImportEntry(CryptoNative_SslDoHandshake) + DllImportEntry(CryptoNative_SslHandshake) + DllImportEntry(CryptoNative_SslEncrypt) + DllImportEntry(CryptoNative_SslDecrypt) DllImportEntry(CryptoNative_SslGetClientCAList) DllImportEntry(CryptoNative_SslGetCurrentCipherId) DllImportEntry(CryptoNative_SslGetData) diff --git a/src/native/libs/System.Security.Cryptography.Native/opensslshim.h b/src/native/libs/System.Security.Cryptography.Native/opensslshim.h index 22425beb8a4126..d789b6ab3dc857 100644 --- a/src/native/libs/System.Security.Cryptography.Native/opensslshim.h +++ b/src/native/libs/System.Security.Cryptography.Native/opensslshim.h @@ -331,13 +331,27 @@ extern bool g_libSslUses32BitTime; REQUIRED_FUNCTION(ASN1_TIME_set) \ REQUIRED_FUNCTION(ASN1_TIME_to_tm) \ REQUIRED_FUNCTION(ASN1_TIME_free) \ + REQUIRED_FUNCTION(BIO_clear_flags) \ REQUIRED_FUNCTION(BIO_ctrl) \ REQUIRED_FUNCTION(BIO_ctrl_pending) \ REQUIRED_FUNCTION(BIO_free) \ + REQUIRED_FUNCTION(BIO_get_data) \ + REQUIRED_FUNCTION(BIO_get_new_index) \ REQUIRED_FUNCTION(BIO_gets) \ + REQUIRED_FUNCTION(BIO_meth_free) \ + REQUIRED_FUNCTION(BIO_meth_new) \ + REQUIRED_FUNCTION(BIO_meth_set_create) \ + REQUIRED_FUNCTION(BIO_meth_set_ctrl) \ + REQUIRED_FUNCTION(BIO_meth_set_destroy) \ + REQUIRED_FUNCTION(BIO_meth_set_read) \ + REQUIRED_FUNCTION(BIO_meth_set_write) \ REQUIRED_FUNCTION(BIO_new) \ REQUIRED_FUNCTION(BIO_new_file) \ REQUIRED_FUNCTION(BIO_read) \ + REQUIRED_FUNCTION(BIO_set_data) \ + REQUIRED_FUNCTION(BIO_set_flags) \ + REQUIRED_FUNCTION(BIO_set_init) \ + REQUIRED_FUNCTION(BIO_test_flags) \ REQUIRED_FUNCTION(BIO_up_ref) \ REQUIRED_FUNCTION(BIO_s_mem) \ REQUIRED_FUNCTION(BIO_write) \ @@ -737,6 +751,9 @@ extern bool g_libSslUses32BitTime; REQUIRED_FUNCTION(SSL_get_certificate) \ REQUIRED_FUNCTION(SSL_new) \ REQUIRED_FUNCTION(SSL_peek) \ + REQUIRED_FUNCTION(SSL_pending) \ + REQUIRED_FUNCTION(SSL_get_rbio) \ + REQUIRED_FUNCTION(SSL_get_wbio) \ REQUIRED_FUNCTION(SSL_read) \ REQUIRED_FUNCTION(SSL_renegotiate) \ REQUIRED_FUNCTION(SSL_renegotiate_pending) \ @@ -890,13 +907,27 @@ extern TYPEOF(OPENSSL_gmtime)* OPENSSL_gmtime_ptr; #define ASN1_TIME_new ASN1_TIME_new_ptr #define ASN1_TIME_set ASN1_TIME_set_ptr #define ASN1_TIME_to_tm ASN1_TIME_to_tm_ptr +#define BIO_clear_flags BIO_clear_flags_ptr #define BIO_ctrl BIO_ctrl_ptr #define BIO_ctrl_pending BIO_ctrl_pending_ptr #define BIO_free BIO_free_ptr +#define BIO_get_data BIO_get_data_ptr +#define BIO_get_new_index BIO_get_new_index_ptr #define BIO_gets BIO_gets_ptr +#define BIO_meth_free BIO_meth_free_ptr +#define BIO_meth_new BIO_meth_new_ptr +#define BIO_meth_set_create BIO_meth_set_create_ptr +#define BIO_meth_set_ctrl BIO_meth_set_ctrl_ptr +#define BIO_meth_set_destroy BIO_meth_set_destroy_ptr +#define BIO_meth_set_read BIO_meth_set_read_ptr +#define BIO_meth_set_write BIO_meth_set_write_ptr #define BIO_new BIO_new_ptr #define BIO_new_file BIO_new_file_ptr #define BIO_read BIO_read_ptr +#define BIO_set_data BIO_set_data_ptr +#define BIO_set_flags BIO_set_flags_ptr +#define BIO_set_init BIO_set_init_ptr +#define BIO_test_flags BIO_test_flags_ptr #define BIO_up_ref BIO_up_ref_ptr #define BIO_s_mem BIO_s_mem_ptr #define BIO_write BIO_write_ptr @@ -1301,6 +1332,9 @@ extern TYPEOF(OPENSSL_gmtime)* OPENSSL_gmtime_ptr; #define SSL_is_init_finished SSL_is_init_finished_ptr #define SSL_new SSL_new_ptr #define SSL_peek SSL_peek_ptr +#define SSL_pending SSL_pending_ptr +#define SSL_get_rbio SSL_get_rbio_ptr +#define SSL_get_wbio SSL_get_wbio_ptr #define SSL_read SSL_read_ptr #define SSL_renegotiate SSL_renegotiate_ptr #define SSL_renegotiate_pending SSL_renegotiate_pending_ptr diff --git a/src/native/libs/System.Security.Cryptography.Native/pal_bio.c b/src/native/libs/System.Security.Cryptography.Native/pal_bio.c index f0a4ce45d06bf7..5c3e205c43f77a 100644 --- a/src/native/libs/System.Security.Cryptography.Native/pal_bio.c +++ b/src/native/libs/System.Security.Cryptography.Native/pal_bio.c @@ -4,6 +4,9 @@ #include "pal_bio.h" #include +#include +#include +#include BIO* CryptoNative_CreateMemoryBio(void) { @@ -58,3 +61,421 @@ int32_t CryptoNative_BioCtrlPending(BIO* bio) assert(result <= INT32_MAX); return (int32_t)result; } + +/* + * Managed-span BIO + * ---------------- + * + * OpenSSL drives TLS by reading ciphertext from an "input BIO" and writing + * ciphertext to an "output BIO" that the application owns. The stock + * BIO_s_mem() implementation works, but it always copies the caller's data + * into an internal heap buffer and then OpenSSL copies again on the way out, + * resulting in two memcpys per TLS record in each direction. + * + * The "managed-span" BIO_METHOD defined below avoids one of those copies in + * each direction by letting the SSL operation read from / write to a + * caller-supplied buffer "window" directly. It is paired with the single-shot + * SSL_{Handshake,Encrypt,Decrypt} entry points in pal_ssl.c which install + * the windows around an SSL operation and tear them down again afterwards. + * + * Lifetimes + * ~~~~~~~~~ + * The windows are only valid for the duration of a single SSL_* call and + * are installed via the (non-exported) helpers + * CryptoNative_BioSetReadWindow / CryptoNative_BioClearReadWindow + * CryptoNative_BioSetWriteWindow / CryptoNative_BioGetWriteResult + * (see pal_ssl.c). The caller pins/fixes the underlying managed buffers for + * exactly that scope, then unpins them on return. The BIO context itself + * outlives the SSL handle and persists across calls; only the {read,write}Ptr + * fields refer to caller memory while a call is in progress. + * + * Read side + * ~~~~~~~~~ + * BioSetReadWindow records (ptr, len) of the caller's ciphertext span. + * BIO_read inside SSL copies from there directly (one memcpy, into OpenSSL's + * record decode buffer). After the SSL call returns, BioClearReadWindow + * reports how many bytes are still unread (= len - readPos) so the caller + * knows what to keep in its own buffer for the next round; the window pointer + * is then cleared. If SSL did not advance the BIO at all (e.g. a renegotiate + * state-machine quirk that returns SSL_ERROR_NONE without consuming bytes) + * the unread tail simply stays in the caller's buffer and is re-supplied on + * the next call - the BIO holds no carry buffer of its own. + * + * Write side + * ~~~~~~~~~~ + * BioSetWriteWindow records (ptr, capacity) of the caller's outgoing-token + * buffer. BIO_write fills the window first (one memcpy, from OpenSSL's record + * encode buffer into the caller's span). If OpenSSL produces more output than + * fits in the window (because our upper-bound estimate was too small, or + * because alerts / KeyUpdate frames are emitted out-of-band during an + * SSL_read) the overflow goes into the per-BIO heap "spill" buffer. After the + * SSL call returns, BioGetWriteResult reports both counts; if any spill is + * present the caller drains it with BioDrainSpill (one extra memcpy, but only + * on the rare overflow path). + * + * Spill buffer reuse + * ~~~~~~~~~~~~~~~~~~ + * The spill buffer is owned by the BIO context (allocated lazily, grown by + * doubling, freed in BioDestroy). It is also the catch-all for output that + * OpenSSL writes outside an explicit window - notably TLS 1.3 KeyUpdate / + * post-handshake auth messages emitted while SSL_read is in progress. The + * managed wrapper drains the spill at the start of the next outgoing SSL + * operation so those bytes are not lost. + */ + +typedef struct +{ + const uint8_t* readPtr; + int32_t readLen; + int32_t readPos; + + uint8_t* writePtr; + int32_t writeCapacity; + int32_t writePos; + + uint8_t* spillBuf; + int32_t spillCapacity; + int32_t spillLen; +} ManagedSpanBioCtx; + +static ManagedSpanBioCtx* GetManagedSpanBioCtx(BIO* bio) +{ + return (ManagedSpanBioCtx*)BIO_get_data(bio); +} + +#define MANAGED_SPAN_SPILL_INITIAL 4096 + +static BIO_METHOD* g_managedSpanBioMethod = NULL; +static pthread_once_t g_managedSpanBioOnce = PTHREAD_ONCE_INIT; + +static int ManagedSpanBioRead(BIO* bio, char* buf, int len) +{ + if (bio == NULL || buf == NULL || len <= 0) + { + return 0; + } + + BIO_clear_retry_flags(bio); + + ManagedSpanBioCtx* ctx = GetManagedSpanBioCtx(bio); + if (ctx == NULL) + { + return -1; + } + + int32_t available = ctx->readLen - ctx->readPos; + if (available <= 0 || ctx->readPtr == NULL) + { + BIO_set_retry_read(bio); + return -1; + } + + int32_t toCopy = len < available ? len : available; + memcpy(buf, ctx->readPtr + ctx->readPos, (size_t)toCopy); + ctx->readPos += toCopy; + return toCopy; +} + +static int ManagedSpanBioGrowSpill(ManagedSpanBioCtx* ctx, int32_t needed) +{ + if (ctx->spillCapacity >= needed) + { + return 1; + } + + int32_t newCap = ctx->spillCapacity > 0 ? ctx->spillCapacity : MANAGED_SPAN_SPILL_INITIAL; + while (newCap < needed) + { + if (newCap > INT32_MAX / 2) + { + newCap = needed; + break; + } + newCap *= 2; + } + + uint8_t* newBuf = (uint8_t*)realloc(ctx->spillBuf, (size_t)newCap); + if (newBuf == NULL) + { + return 0; + } + + ctx->spillBuf = newBuf; + ctx->spillCapacity = newCap; + return 1; +} + +static int ManagedSpanBioWrite(BIO* bio, const char* buf, int len) +{ + if (bio == NULL || buf == NULL || len < 0) + { + return 0; + } + + BIO_clear_retry_flags(bio); + + if (len == 0) + { + return 0; + } + + ManagedSpanBioCtx* ctx = GetManagedSpanBioCtx(bio); + if (ctx == NULL) + { + return -1; + } + + int32_t remaining = len; + const uint8_t* src = (const uint8_t*)buf; + + if (ctx->writePtr != NULL) + { + int32_t windowAvail = ctx->writeCapacity - ctx->writePos; + if (windowAvail > 0) + { + int32_t toCopy = remaining < windowAvail ? remaining : windowAvail; + memcpy(ctx->writePtr + ctx->writePos, src, (size_t)toCopy); + ctx->writePos += toCopy; + src += toCopy; + remaining -= toCopy; + } + } + + if (remaining > 0) + { + // Guard against int32 overflow before computing the new spill size. + // remaining and spillLen are both non-negative int32; bail out if the + // sum would not fit so ManagedSpanBioGrowSpill cannot be tricked into + // sizing the buffer based on a wrapped value. + if (remaining > INT32_MAX - ctx->spillLen) + { + return -1; + } + int32_t needed = ctx->spillLen + remaining; + if (!ManagedSpanBioGrowSpill(ctx, needed)) + { + return -1; + } + memcpy(ctx->spillBuf + ctx->spillLen, src, (size_t)remaining); + ctx->spillLen += remaining; + } + + return len; +} + +static long ManagedSpanBioCtrl(BIO* bio, int cmd, long num, void* ptr) +{ + (void)bio; + (void)num; + (void)ptr; + + // OpenSSL only invokes a small set of ctrl commands against this BIO when it is plugged + // into the SSL state machine via SSL_set_bio. Empirically (verified across the full + // System.Net.Security test suite) only BIO_CTRL_FLUSH needs a real response. Returning 0 + // from the default case correctly answers BIO_CTRL_PUSH/POP (no-op), the kTLS probes + // BIO_CTRL_GET_KTLS_SEND/RECV (not supported), and any other future query. We + // deliberately do not implement BIO_CTRL_RESET: the SSL flows we exercise never issue + // it, and a real reset would have to either preserve or drop the spill buffer of bytes + // that have not yet been drained - neither of which is a safe default. Returning 0 for + // an unhandled command surfaces that explicitly to OpenSSL rather than pretending + // success. + if (cmd == BIO_CTRL_FLUSH) + { + return 1; + } + return 0; +} + +static int ManagedSpanBioCreate(BIO* bio) +{ + ManagedSpanBioCtx* ctx = (ManagedSpanBioCtx*)calloc(1, sizeof(ManagedSpanBioCtx)); + if (ctx == NULL) + { + return 0; + } + + BIO_set_data(bio, ctx); + BIO_set_init(bio, 1); + return 1; +} + +static int ManagedSpanBioDestroy(BIO* bio) +{ + if (bio == NULL) + { + return 0; + } + + ManagedSpanBioCtx* ctx = GetManagedSpanBioCtx(bio); + if (ctx != NULL) + { + free(ctx->spillBuf); + free(ctx); + BIO_set_data(bio, NULL); + } + BIO_set_init(bio, 0); + return 1; +} + +static void ManagedSpanBioMethodInit(void) +{ + int index = BIO_get_new_index(); + if (index == -1) + { + return; + } + + BIO_METHOD* method = BIO_meth_new(index | BIO_TYPE_SOURCE_SINK, "dotnet-managed-span"); + if (method == NULL) + { + return; + } + + if (!BIO_meth_set_write(method, ManagedSpanBioWrite) || + !BIO_meth_set_read(method, ManagedSpanBioRead) || + !BIO_meth_set_ctrl(method, ManagedSpanBioCtrl) || + !BIO_meth_set_create(method, ManagedSpanBioCreate) || + !BIO_meth_set_destroy(method, ManagedSpanBioDestroy)) + { + BIO_meth_free(method); + return; + } + + g_managedSpanBioMethod = method; +} + +static BIO_METHOD* GetManagedSpanBioMethod(void) +{ + pthread_once(&g_managedSpanBioOnce, ManagedSpanBioMethodInit); + return g_managedSpanBioMethod; +} + +BIO* CryptoNative_BioNewManagedSpan(void) +{ + ERR_clear_error(); + + BIO_METHOD* method = GetManagedSpanBioMethod(); + if (method == NULL) + { + return NULL; + } + + return BIO_new(method); +} + +void CryptoNative_BioSetReadWindow(BIO* bio, const void* ptr, int32_t len) +{ + if (bio == NULL) + { + return; + } + + ManagedSpanBioCtx* ctx = GetManagedSpanBioCtx(bio); + if (ctx == NULL) + { + return; + } + + ctx->readPtr = (const uint8_t*)ptr; + ctx->readLen = ptr != NULL ? len : 0; + ctx->readPos = 0; +} + +void CryptoNative_BioClearReadWindow(BIO* bio, int32_t* leftoverLength) +{ + if (bio == NULL) + { + return; + } + + ManagedSpanBioCtx* ctx = GetManagedSpanBioCtx(bio); + if (ctx == NULL) + { + return; + } + + if (leftoverLength != NULL) + { + *leftoverLength = ctx->readLen - ctx->readPos; + } + + ctx->readPtr = NULL; + ctx->readLen = 0; + ctx->readPos = 0; +} + +void CryptoNative_BioSetWriteWindow(BIO* bio, void* ptr, int32_t capacity) +{ + if (bio == NULL) + { + return; + } + + ManagedSpanBioCtx* ctx = GetManagedSpanBioCtx(bio); + if (ctx == NULL) + { + return; + } + + ctx->writePtr = (uint8_t*)ptr; + ctx->writeCapacity = ptr != NULL ? capacity : 0; + ctx->writePos = 0; +} + +void CryptoNative_BioGetWriteResult(BIO* bio, int32_t* writtenToWindow, int32_t* spillLen) +{ + if (writtenToWindow != NULL) + { + *writtenToWindow = 0; + } + if (spillLen != NULL) + { + *spillLen = 0; + } + + if (bio == NULL) + { + return; + } + + ManagedSpanBioCtx* ctx = GetManagedSpanBioCtx(bio); + if (ctx == NULL) + { + return; + } + + if (writtenToWindow != NULL) + { + *writtenToWindow = ctx->writePos; + } + if (spillLen != NULL) + { + *spillLen = ctx->spillLen; + } +} + +int32_t CryptoNative_BioDrainSpill(BIO* bio, void* dst, int32_t dstLen) +{ + if (bio == NULL || dst == NULL || dstLen <= 0) + { + return 0; + } + + ManagedSpanBioCtx* ctx = GetManagedSpanBioCtx(bio); + if (ctx == NULL || ctx->spillLen == 0) + { + return 0; + } + + int32_t toCopy = dstLen < ctx->spillLen ? dstLen : ctx->spillLen; + memcpy(dst, ctx->spillBuf, (size_t)toCopy); + + int32_t remaining = ctx->spillLen - toCopy; + if (remaining > 0) + { + memmove(ctx->spillBuf, ctx->spillBuf + toCopy, (size_t)remaining); + } + ctx->spillLen = remaining; + return toCopy; +} + diff --git a/src/native/libs/System.Security.Cryptography.Native/pal_bio.h b/src/native/libs/System.Security.Cryptography.Native/pal_bio.h index 7f1a395419e4f7..f0520caf991252 100644 --- a/src/native/libs/System.Security.Cryptography.Native/pal_bio.h +++ b/src/native/libs/System.Security.Cryptography.Native/pal_bio.h @@ -54,3 +54,41 @@ Shims the BIO_ctrl_pending method. Returns the number of pending characters in the BIOs read and write buffers. */ PALEXPORT int32_t CryptoNative_BioCtrlPending(BIO* bio); + +/* +Creates a new BIO using the managed-span BIO_METHOD that operates on +caller-supplied buffer windows (with a heap spill on write overflow). +*/ +PALEXPORT BIO* CryptoNative_BioNewManagedSpan(void); + +/* +Internal helpers used by pal_ssl.c to drive the managed-span BIO during +single-shot SSL handshake/encrypt/decrypt operations. Not exported. +*/ +void CryptoNative_BioSetReadWindow(BIO* bio, const void* ptr, int32_t len); +void CryptoNative_BioClearReadWindow(BIO* bio, int32_t* leftoverLength); +void CryptoNative_BioSetWriteWindow(BIO* bio, void* ptr, int32_t capacity); + +/* +Reports the current state of the managed-span output BIO after an SSL +operation. + +writtenToWindow is the number of bytes BIO_write deposited directly into +the current caller-supplied window since the last CryptoNative_BioSetWriteWindow +call. CryptoNative_BioSetWriteWindow resets this counter to zero each time +a new window is installed. + +spillLen is the total number of bytes currently held in the per-BIO heap +spill buffer. The spill is *not* reset by CryptoNative_BioSetWriteWindow; +it accumulates across SSL operations (so out-of-band output such as alerts +or TLS 1.3 KeyUpdate frames emitted during SSL_read is preserved for the +caller) and is only drained by CryptoNative_BioDrainSpill. +*/ +PALEXPORT void CryptoNative_BioGetWriteResult(BIO* bio, int32_t* writtenToWindow, int32_t* spillLen); + +/* +Drains up to dstLen bytes from the start of the spill buffer into dst, +shifting the rest down. Returns the number of bytes drained. +*/ +PALEXPORT int32_t CryptoNative_BioDrainSpill(BIO* bio, void* dst, int32_t dstLen); + diff --git a/src/native/libs/System.Security.Cryptography.Native/pal_ssl.c b/src/native/libs/System.Security.Cryptography.Native/pal_ssl.c index 0648508b513f16..62539c77ca288f 100644 --- a/src/native/libs/System.Security.Cryptography.Native/pal_ssl.c +++ b/src/native/libs/System.Security.Cryptography.Native/pal_ssl.c @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #include "pal_ssl.h" +#include "pal_bio.h" #include "openssl.h" #include "pal_evp_pkey.h" #include "pal_evp_pkey_rsa.h" @@ -455,10 +456,8 @@ int32_t CryptoNative_SslRenegotiate(SSL* ssl, int32_t* error) if(ret != 1) { *error = CryptoNative_SslGetError(ssl, ret); - return ret; } - - return CryptoNative_SslDoHandshake(ssl, error); + return ret; } *error = SSL_ERROR_NONE; @@ -469,7 +468,6 @@ int32_t CryptoNative_IsSslRenegotiatePending(SSL* ssl) { ERR_clear_error(); - SSL_peek(ssl, NULL, 0); return SSL_renegotiate_pending(ssl) != 0; } @@ -485,17 +483,151 @@ void CryptoNative_SslSetBio(SSL* ssl, BIO* rbio, BIO* wbio) SSL_set_bio(ssl, rbio, wbio); } -int32_t CryptoNative_SslDoHandshake(SSL* ssl, int32_t* error) +int32_t CryptoNative_SslHandshake( + SSL* ssl, + const uint8_t* inputPtr, + int32_t inputLen, + int32_t* consumed, + uint8_t* outputPtr, + int32_t outputCap, + int32_t* outputWritten, + int32_t* outputPending, + int32_t* errorCode) { + if (outputWritten != NULL) *outputWritten = 0; + if (outputPending != NULL) *outputPending = 0; + if (consumed != NULL) *consumed = 0; + ERR_clear_error(); + + BIO* inputBio = SSL_get_rbio(ssl); + BIO* outputBio = SSL_get_wbio(ssl); + + if (inputBio != NULL) + { + CryptoNative_BioSetReadWindow(inputBio, inputPtr, inputLen); + } + if (outputBio != NULL) + { + CryptoNative_BioSetWriteWindow(outputBio, outputPtr, outputCap); + } + + // this peek ensures that the SSL handshake state machine starts processing + // renegotiation and post-handshake client cert requests + SSL_peek(ssl, NULL, 0); + int32_t result = SSL_do_handshake(ssl); - if (result == 1) + *errorCode = (result == 1) ? SSL_ERROR_NONE : CryptoNative_SslGetError(ssl, result); + + if (outputBio != NULL) { - *error = SSL_ERROR_NONE; + CryptoNative_BioGetWriteResult(outputBio, outputWritten, outputPending); + CryptoNative_BioSetWriteWindow(outputBio, NULL, 0); } - else + if (inputBio != NULL) { - *error = CryptoNative_SslGetError(ssl, result); + int32_t leftover = 0; + CryptoNative_BioClearReadWindow(inputBio, &leftover); + if (consumed != NULL) + { + *consumed = inputLen - leftover; + } + } + + return result; +} + +int32_t CryptoNative_SslEncrypt( + SSL* ssl, + const uint8_t* plaintextPtr, + int32_t plaintextLen, + uint8_t* outputPtr, + int32_t outputCap, + int32_t* outputWritten, + int32_t* outputPending, + int32_t* errorCode) +{ + if (outputWritten != NULL) *outputWritten = 0; + if (outputPending != NULL) *outputPending = 0; + + ERR_clear_error(); + + BIO* outputBio = SSL_get_wbio(ssl); + if (outputBio != NULL) + { + CryptoNative_BioSetWriteWindow(outputBio, outputPtr, outputCap); + } + + int32_t result = SSL_write(ssl, plaintextPtr, plaintextLen); + *errorCode = (result > 0) ? SSL_ERROR_NONE : CryptoNative_SslGetError(ssl, result); + + if (outputBio != NULL) + { + CryptoNative_BioGetWriteResult(outputBio, outputWritten, outputPending); + CryptoNative_BioSetWriteWindow(outputBio, NULL, 0); + } + + return result; +} + +int32_t CryptoNative_SslDecrypt( + SSL* ssl, + uint8_t* inputPtr, + int32_t inputLen, + int32_t* consumed, + uint8_t* outputPtr, + int32_t outputCap, + int32_t* leftoverOffset, + int32_t* leftoverLength, + int32_t* errorCode) +{ + if (leftoverOffset != NULL) *leftoverOffset = 0; + if (leftoverLength != NULL) *leftoverLength = 0; + if (consumed != NULL) *consumed = 0; + + ERR_clear_error(); + + BIO* inputBio = SSL_get_rbio(ssl); + if (inputBio != NULL) + { + CryptoNative_BioSetReadWindow(inputBio, inputPtr, inputLen); + } + + int32_t result = 0; + int32_t leftover = 0; + + if (outputCap > 0) + { + result = SSL_read(ssl, outputPtr, outputCap); + } + + // If the caller-provided destination is empty or full, look for additional plaintext that + // SSL would otherwise buffer internally and stash it in-place in the input buffer so the + // caller can pick it up on the next call. Skip the probe when the first SSL_read already + // failed - that error/state should be reported as-is. + if (result > 0 ? SSL_pending(ssl) > 0 : outputCap == 0) + { + leftover = SSL_read(ssl, inputPtr, inputLen); + } + + // The first SSL_read determines the outcome when it produced bytes; otherwise the second + // SSL_read (the one that was actually attempted) does. Only report leftoverLength when it + // is positive - a negative value from a failed second read is not user-visible plaintext. + int32_t outcome = result > 0 ? result : leftover; + *errorCode = (outcome > 0) ? SSL_ERROR_NONE : CryptoNative_SslGetError(ssl, outcome); + if (leftover > 0) + { + *leftoverLength = leftover; + } + + if (inputBio != NULL) + { + int32_t unread = 0; + CryptoNative_BioClearReadWindow(inputBio, &unread); + if (consumed != NULL) + { + *consumed = inputLen - unread; + } } return result; diff --git a/src/native/libs/System.Security.Cryptography.Native/pal_ssl.h b/src/native/libs/System.Security.Cryptography.Native/pal_ssl.h index 7ea042b960cd70..48e0a734c2ec65 100644 --- a/src/native/libs/System.Security.Cryptography.Native/pal_ssl.h +++ b/src/native/libs/System.Security.Cryptography.Native/pal_ssl.h @@ -315,15 +315,85 @@ Shims the SSL_set_bio method. PALEXPORT void CryptoNative_SslSetBio(SSL* ssl, BIO* rbio, BIO* wbio); /* -Shims the SSL_do_handshake method. - -Returns: -1 if the handshake was successful; -0 if the handshake was not successful but was shut down controlled -and by the specifications of the TLS/SSL protocol; -<0 if the handshake was not successful because of a fatal error. -*/ -PALEXPORT int32_t CryptoNative_SslDoHandshake(SSL* ssl, int32_t* error); +Performs SSL_do_handshake with the input/output BIO windows set up +and torn down in a single P/Invoke. The input BIO window points at inputPtr +(ciphertext from peer, may be NULL/0). The output BIO window receives outgoing +handshake bytes into outputPtr/outputCap; outputWritten reports the count +placed there. If the handshake emitted more bytes than outputCap, those +overflow into the BIO spill and outputPending reports their size (caller drains +via CryptoNative_BioDrainSpill). + +Returns the SSL_do_handshake return value; errorCode receives SSL_get_error. +*/ +PALEXPORT int32_t CryptoNative_SslHandshake( + SSL* ssl, + const uint8_t* inputPtr, + int32_t inputLen, + int32_t* consumed, + uint8_t* outputPtr, + int32_t outputCap, + int32_t* outputWritten, + int32_t* outputPending, + int32_t* errorCode); + +/* +Performs SSL_write with the output BIO window set up and torn down +in a single P/Invoke. plaintextPtr/plaintextLen is the plaintext. outputPtr/ +outputCap is the ciphertext destination window; outputWritten reports bytes +written, outputPending reports any overflow now in the BIO spill (drain via +CryptoNative_BioDrainSpill). + +Returns the SSL_write return value; errorCode receives SSL_get_error. +*/ +PALEXPORT int32_t CryptoNative_SslEncrypt( + SSL* ssl, + const uint8_t* plaintextPtr, + int32_t plaintextLen, + uint8_t* outputPtr, + int32_t outputCap, + int32_t* outputWritten, + int32_t* outputPending, + int32_t* errorCode); + +/* +Performs SSL_read with the input BIO window set up and torn down +in a single P/Invoke. + +inputPtr/inputLen describes the incoming ciphertext window. The buffer is +also reused as in-place scratch space for plaintext that does not fit into +the caller-supplied destination (see leftoverOffset/leftoverLength below), so +inputPtr must point at writable memory. + +outputPtr/outputCap is the primary plaintext destination. The function reads +up to outputCap bytes of plaintext directly into outputPtr and returns that +count via the function return value. + +If outputCap is 0, or if OpenSSL has more plaintext available for the current +record after the first SSL_read (SSL_pending > 0, e.g. the destination was +smaller than the record), a second SSL_read drains the remaining plaintext +back into the input buffer in place starting at inputPtr. The leftover region +is reported through leftoverOffset / leftoverLength (offset is always 0 on +Unix; the Windows PAL uses non-zero offsets and the managed contract is +shared across PALs). The caller is expected to forward those bytes to its +internal decrypted-data buffer. + +consumed receives the number of ciphertext bytes the BIO consumed from the +input window (inputLen minus any unread tail). errorCode receives SSL_get_error +mapped on the SSL_read result (or on the second SSL_read if the first did +not produce data). + +Returns the first SSL_read return value (>0 = bytes written to outputPtr). +*/ +PALEXPORT int32_t CryptoNative_SslDecrypt( + SSL* ssl, + uint8_t* inputPtr, + int32_t inputLen, + int32_t* consumed, + uint8_t* outputPtr, + int32_t outputCap, + int32_t* leftoverOffset, + int32_t* leftoverLength, + int32_t* errorCode); /* Gets a value indicating whether the SSL_state is SSL_ST_OK.