From 08f08bf837b2a2dddac8d8cfd06cb5c2bda91c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl=20H=C3=B6rberg?= Date: Thu, 5 Feb 2026 21:42:19 +0100 Subject: [PATCH] Switch OpenSSL::SSL::Socket from custom BIO to SSL_set_fd Replace the Crystal BIO wrapper with SSL_set_fd() so OpenSSL operates directly on the socket file descriptor. This enables the kernel to intercept socket operations for kTLS (kernel TLS) when available. - Add SSL_set_fd, SSL_get_rbio, SSL_get_wbio bindings to LibSSL - Add BIO_ctrl binding to LibCrypto - Type-restrict `io` parameter to `::Socket` (all callers already pass TCPSocket) - Handle WANT_READ/WANT_WRITE during handshake, read, write, and shutdown by waiting via Crystal::EventLoop - Enable ENABLE_PARTIAL_WRITE mode for proper non-blocking write behavior with SSL_set_fd - Add ktls_send?/ktls_recv? status methods - Simplify timeout/address property delegation Co-Authored-By: Claude Opus 4.6 --- spec/std/openssl/ssl/context_spec.cr | 4 +- src/openssl/lib_crypto.cr | 2 + src/openssl/lib_ssl.cr | 3 + src/openssl/ssl/context.cr | 2 +- src/openssl/ssl/server.cr | 6 +- src/openssl/ssl/socket.cr | 162 ++++++++++++++++----------- 6 files changed, 109 insertions(+), 70 deletions(-) diff --git a/spec/std/openssl/ssl/context_spec.cr b/spec/std/openssl/ssl/context_spec.cr index 2836ce491532..65884bc07d57 100644 --- a/spec/std/openssl/ssl/context_spec.cr +++ b/spec/std/openssl/ssl/context_spec.cr @@ -13,7 +13,7 @@ describe OpenSSL::SSL::Context do (context.options & OpenSSL::SSL::Options::ALL).should eq(OpenSSL::SSL::Options::ALL) (context.options & OpenSSL::SSL::Options::NO_SESSION_RESUMPTION_ON_RENEGOTIATION).should eq(OpenSSL::SSL::Options::NO_SESSION_RESUMPTION_ON_RENEGOTIATION) - context.modes.should eq(OpenSSL::SSL::Modes.flags(AUTO_RETRY, RELEASE_BUFFERS)) + context.modes.should eq(OpenSSL::SSL::Modes.flags(AUTO_RETRY, RELEASE_BUFFERS, ENABLE_PARTIAL_WRITE)) context.verify_mode.should eq(OpenSSL::SSL::VerifyMode::PEER) OpenSSL::SSL::Context::Client.new(LibSSL.tlsv1_method) @@ -26,7 +26,7 @@ describe OpenSSL::SSL::Context do (context.options & OpenSSL::SSL::Options::NO_SESSION_RESUMPTION_ON_RENEGOTIATION).should eq(OpenSSL::SSL::Options::NO_SESSION_RESUMPTION_ON_RENEGOTIATION) (context.options & OpenSSL::SSL::Options::NO_RENEGOTIATION).should eq(OpenSSL::SSL::Options::NO_RENEGOTIATION) - context.modes.should eq(OpenSSL::SSL::Modes.flags(AUTO_RETRY, RELEASE_BUFFERS)) + context.modes.should eq(OpenSSL::SSL::Modes.flags(AUTO_RETRY, RELEASE_BUFFERS, ENABLE_PARTIAL_WRITE)) context.verify_mode.should eq(OpenSSL::SSL::VerifyMode::NONE) OpenSSL::SSL::Context::Server.new(LibSSL.tlsv1_method) diff --git a/src/openssl/lib_crypto.cr b/src/openssl/lib_crypto.cr index 2a3e922a251b..3c342a2752ec 100644 --- a/src/openssl/lib_crypto.cr +++ b/src/openssl/lib_crypto.cr @@ -111,6 +111,8 @@ lib LibCrypto type BioMethod = Void + fun BIO_ctrl(bio : Bio*, cmd : Int, larg : Long, parg : Void*) : Long + fun BIO_new(BioMethod*) : Bio* fun BIO_free(Bio*) : Int diff --git a/src/openssl/lib_ssl.cr b/src/openssl/lib_ssl.cr index 633247c3268e..f2ab54e6a863 100644 --- a/src/openssl/lib_ssl.cr +++ b/src/openssl/lib_ssl.cr @@ -214,6 +214,9 @@ lib LibSSL fun ssl_get_error = SSL_get_error(handle : SSL, ret : Int) : SSLError fun ssl_get_servername = SSL_get_servername(ssl : SSL, host_type : TLSExt) : UInt8* fun ssl_set_bio = SSL_set_bio(handle : SSL, rbio : LibCrypto::Bio*, wbio : LibCrypto::Bio*) + fun ssl_set_fd = SSL_set_fd(handle : SSL, fd : Int) : Int + fun ssl_get_rbio = SSL_get_rbio(handle : SSL) : LibCrypto::Bio* + fun ssl_get_wbio = SSL_get_wbio(handle : SSL) : LibCrypto::Bio* fun ssl_select_next_proto = SSL_select_next_proto(output : Char**, output_len : Char*, input : Char*, input_len : Int, client : Char*, client_len : Int) : Int fun ssl_ctrl = SSL_ctrl(handle : SSL, cmd : Int, larg : Long, parg : Void*) : Long fun ssl_free = SSL_free(handle : SSL) diff --git a/src/openssl/ssl/context.cr b/src/openssl/ssl/context.cr index 61ded0c92176..cff7a544daa4 100644 --- a/src/openssl/ssl/context.cr +++ b/src/openssl/ssl/context.cr @@ -259,7 +259,7 @@ abstract class OpenSSL::SSL::Context NO_SESSION_RESUMPTION_ON_RENEGOTIATION, NO_RENEGOTIATION, )) - add_modes(OpenSSL::SSL::Modes.flags(AUTO_RETRY, RELEASE_BUFFERS)) + add_modes(OpenSSL::SSL::Modes.flags(AUTO_RETRY, RELEASE_BUFFERS, ENABLE_PARTIAL_WRITE)) # OpenSSL does not support reading from the system root certificate store on # Windows, so we have to import them ourselves diff --git a/src/openssl/ssl/server.cr b/src/openssl/ssl/server.cr index 97132f8c4810..a2b8f643d4f9 100644 --- a/src/openssl/ssl/server.cr +++ b/src/openssl/ssl/server.cr @@ -63,7 +63,7 @@ class OpenSSL::SSL::Server # # This method calls `@wrapped.accept` and wraps the resulting IO in a SSL socket (`OpenSSL::SSL::Socket::Server`) with `context` configuration. def accept : OpenSSL::SSL::Socket::Server - new_ssl_socket(@wrapped.accept) + new_ssl_socket(@wrapped.accept.as(::Socket)) end # Implements `::Socket::Server#accept?`. @@ -71,11 +71,11 @@ class OpenSSL::SSL::Server # This method calls `@wrapped.accept?` and wraps the resulting IO in a SSL socket (`OpenSSL::SSL::Socket::Server`) with `context` configuration. def accept? : OpenSSL::SSL::Socket::Server? if socket = @wrapped.accept? - new_ssl_socket(socket) + new_ssl_socket(socket.as(::Socket)) end end - private def new_ssl_socket(io) + private def new_ssl_socket(io : ::Socket) OpenSSL::SSL::Socket::Server.new(io, @context, sync_close: @sync_close, accept: @start_immediately) end diff --git a/src/openssl/ssl/socket.cr b/src/openssl/ssl/socket.cr index a8ad2ed222bc..d8f49f17c346 100644 --- a/src/openssl/ssl/socket.cr +++ b/src/openssl/ssl/socket.cr @@ -1,6 +1,6 @@ abstract class OpenSSL::SSL::Socket < IO class Client < Socket - def initialize(io, context : Context::Client = Context::Client.new, sync_close : Bool = false, hostname : String? = nil) + def initialize(io : ::Socket, context : Context::Client = Context::Client.new, sync_close : Bool = false, hostname : String? = nil) super(io, context, sync_close) begin if hostname @@ -25,9 +25,15 @@ abstract class OpenSSL::SSL::Socket < IO end end - ret = LibSSL.ssl_connect(@ssl) - unless ret == 1 - raise OpenSSL::SSL::Error.new(@ssl, ret, "SSL_connect") + loop do + ret = LibSSL.ssl_connect(@ssl) + break if ret == 1 + error = LibSSL.ssl_get_error(@ssl, ret) + case error + when .want_read? then wait_readable + when .want_write? then wait_writable + else raise OpenSSL::SSL::Error.new(@ssl, ret, "SSL_connect") + end end rescue ex LibSSL.ssl_free(@ssl) # GC never calls finalize, avoid mem leak @@ -52,7 +58,7 @@ abstract class OpenSSL::SSL::Socket < IO end class Server < Socket - def initialize(io, context : Context::Server = Context::Server.new, + def initialize(io : ::Socket, context : Context::Server = Context::Server.new, sync_close : Bool = false, accept : Bool = true) super(io, context, sync_close) @@ -67,10 +73,17 @@ abstract class OpenSSL::SSL::Socket < IO end def accept : Nil - ret = LibSSL.ssl_accept(@ssl) - unless ret == 1 - @bio.io.close if @sync_close - raise OpenSSL::SSL::Error.new(@ssl, ret, "SSL_accept") + loop do + ret = LibSSL.ssl_accept(@ssl) + break if ret == 1 + error = LibSSL.ssl_get_error(@ssl, ret) + case error + when .want_read? then wait_readable + when .want_write? then wait_writable + else + @io.close if @sync_close + raise OpenSSL::SSL::Error.new(@ssl, ret, "SSL_accept") + end end end @@ -93,7 +106,10 @@ abstract class OpenSSL::SSL::Socket < IO getter? closed : Bool - protected def initialize(io, context : Context, @sync_close : Bool = false) + # Returns the underlying `::Socket`. + getter io : ::Socket + + protected def initialize(@io : ::Socket, context : Context, @sync_close : Bool = false) @closed = false @ssl = LibSSL.ssl_new(context) @@ -103,13 +119,14 @@ abstract class OpenSSL::SSL::Socket < IO # Since OpenSSL::SSL::Socket is buffered it makes no # sense to wrap a IO::Buffered with buffering activated. - if io.is_a?(IO::Buffered) - io.sync = true - io.read_buffering = false + if @io.is_a?(IO::Buffered) + @io.sync = true + @io.read_buffering = false end - @bio = BIO.new(io) - LibSSL.ssl_set_bio(@ssl, @bio, @bio) + unless LibSSL.ssl_set_fd(@ssl, @io.fd) == 1 + raise OpenSSL::Error.new("SSL_set_fd") + end end def finalize @@ -122,11 +139,21 @@ abstract class OpenSSL::SSL::Socket < IO count = slice.size return 0 if count == 0 - LibSSL.ssl_read(@ssl, slice.to_unsafe, count).tap do |bytes| - if bytes <= 0 && !LibSSL.ssl_get_error(@ssl, bytes).zero_return? - ex = OpenSSL::SSL::Error.new(@ssl, bytes, "SSL_read") + loop do + ret = LibSSL.ssl_read(@ssl, slice.to_unsafe, count) + if ret > 0 + return ret + end + + error = LibSSL.ssl_get_error(@ssl, ret) + case error + when .want_read? then wait_readable + when .want_write? then wait_writable + when .zero_return? then return 0 + else + ex = OpenSSL::SSL::Error.new(@ssl, ret, "SSL_read") if ex.underlying_eof? - # underlying BIO terminated gracefully, without terminating SSL aspect gracefully first + # underlying socket terminated gracefully, without terminating SSL aspect gracefully first # some misbehaving servers "do this" so treat as EOF even though it's a protocol error return 0 end @@ -140,15 +167,23 @@ abstract class OpenSSL::SSL::Socket < IO return if slice.empty? - count = slice.size - bytes = LibSSL.ssl_write(@ssl, slice.to_unsafe, count) - unless bytes > 0 - raise OpenSSL::SSL::Error.new(@ssl, bytes, "SSL_write") + while slice.size > 0 + ret = LibSSL.ssl_write(@ssl, slice.to_unsafe, slice.size) + if ret > 0 + slice += ret + else + error = LibSSL.ssl_get_error(@ssl, ret) + case error + when .want_read? then wait_readable + when .want_write? then wait_writable + else raise OpenSSL::SSL::Error.new(@ssl, ret, "SSL_write") + end + end end end def unbuffered_flush : Nil - @bio.io.flush + @io.flush end # Returns the negotiated ALPN protocol (eg: `"h2"`) of `nil` if no protocol was @@ -167,24 +202,21 @@ abstract class OpenSSL::SSL::Socket < IO ret = LibSSL.ssl_shutdown(@ssl) break if ret == 1 # done bidirectional break if ret == 0 && sync_close? # done unidirectional, "this first successful call to SSL_shutdown() is sufficient" - raise OpenSSL::SSL::Error.new(@ssl, ret, "SSL_shutdown") if ret < 0 - rescue e : OpenSSL::SSL::Error - case e.error - when .want_read?, .want_write? - # Ignore, shutdown did not complete yet - when .syscall? - # OpenSSL claimed an underlying syscall failed, but that didn't set any error state, - # assume we're done - break - else - raise e + if ret < 0 + error = LibSSL.ssl_get_error(@ssl, ret) + case error + when .want_read? then wait_readable + when .want_write? then wait_writable + when .syscall? then break # underlying syscall failed without error state, assume done + else raise OpenSSL::SSL::Error.new(@ssl, ret, "SSL_shutdown") + end end # ret == 0, retry, shutdown is not complete yet end rescue IO::Error ensure - @bio.io.close if @sync_close + @io.close if @sync_close end end @@ -210,49 +242,43 @@ abstract class OpenSSL::SSL::Socket < IO end def local_address - io = @bio.io - io.responds_to?(:local_address) ? io.local_address : nil + io = @io + if io.responds_to?(:local_address) + io.local_address + end end def remote_address - io = @bio.io - io.responds_to?(:remote_address) ? io.remote_address : nil + io = @io + if io.responds_to?(:remote_address) + io.remote_address + end end def read_timeout - io = @bio.io - if io.responds_to? :read_timeout - io.read_timeout - else - raise NotImplementedError.new("#{io.class}#read_timeout") - end + @io.read_timeout end def read_timeout=(value) - io = @bio.io - if io.responds_to? :read_timeout= - io.read_timeout = value - else - raise NotImplementedError.new("#{io.class}#read_timeout=") - end + @io.read_timeout = value end def write_timeout - io = @bio.io - if io.responds_to? :write_timeout - io.write_timeout - else - raise NotImplementedError.new("#{io.class}#write_timeout") - end + @io.write_timeout end def write_timeout=(value) - io = @bio.io - if io.responds_to? :write_timeout= - io.write_timeout = value - else - raise NotImplementedError.new("#{io.class}#write_timeout=") - end + @io.write_timeout = value + end + + # Returns `true` if kTLS is being used for sending data. + def ktls_send? : Bool + LibCrypto.BIO_ctrl(LibSSL.ssl_get_wbio(@ssl), LibCrypto::CTRL_GET_KTLS_SEND, 0, Pointer(Void).null) != 0 + end + + # Returns `true` if kTLS is being used for receiving data. + def ktls_recv? : Bool + LibCrypto.BIO_ctrl(LibSSL.ssl_get_rbio(@ssl), LibCrypto::CTRL_GET_KTLS_RECV, 0, Pointer(Void).null) != 0 end # Returns the `OpenSSL::X509::Certificate` the peer presented, if a @@ -273,4 +299,12 @@ abstract class OpenSSL::SSL::Socket < IO end end end + + private def wait_readable : Nil + Crystal::EventLoop.current.wait_readable(@io) + end + + private def wait_writable : Nil + Crystal::EventLoop.current.wait_writable(@io) + end end