Skip to content

Commit

Permalink
ssl: support IO-like object as an underlying transport
Browse files Browse the repository at this point in the history
Currently, OpenSSL::SSL::SSLSocket requires an IO (socket) object
because it passes the file descriptor to OpenSSL, bypassing Ruby's IO
stack.

There are use cases where somebody wants to establish a TLS connection
on top of a non-OS stream, for example, another TLS connection or an
HTTP/2 tunnel. Doing such a thing currently requires a workaround, such
as using a socket pair.

OpenSSL internally uses an IO abstraction layer called BIO to interact
with the underlying socket in the TLS implementation. We implicitly use
the BIO_s_socket() BIO_METHOD via an SSL_set_fd() call, but we can
provide an alternative BIO_METHOD that wraps Ruby IO methods.

For performance reason, this patch continues to use the socket BIO if
the user passes a real IO object.
  • Loading branch information
rhenium committed Jan 17, 2025
1 parent be5a77a commit 0b0f651
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 33 deletions.
145 changes: 123 additions & 22 deletions ext/openssl/ossl_ssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ static VALUE eSSLErrorWaitReadable;
static VALUE eSSLErrorWaitWritable;

static ID id_call, ID_callback_state, id_tmp_dh_callback,
id_npn_protocols_encoded, id_each;
id_npn_protocols_encoded, id_each, id_bio;
static VALUE sym_exception, sym_wait_readable, sym_wait_writable;

static ID id_i_cert_store, id_i_ca_file, id_i_ca_path, id_i_verify_mode,
Expand Down Expand Up @@ -1596,13 +1596,23 @@ peeraddr_ip_str(VALUE self)
rb_eSystemCallError, (VALUE)0);
}

static int
is_real_socket(VALUE io)
{
// FIXME: DO NOT MERGE
return 0;
return RB_TYPE_P(io, T_FILE);
}

/*
* call-seq:
* SSLSocket.new(io) => aSSLSocket
* SSLSocket.new(io, ctx) => aSSLSocket
*
* Creates a new SSL socket from _io_ which must be a real IO object (not an
* IO-like object that responds to read/write).
* Creates a new SSL socket from the underlying socket _io_ and _ctx_.
*
* _io_ must be an IO object, typically a TCPSocket or Socket from the socket
* library, or an IO-like object that supports the typical IO methods.
*
* If _ctx_ is provided the SSL Sockets initial params will be taken from
* the context.
Expand All @@ -1611,6 +1621,22 @@ peeraddr_ip_str(VALUE self)
*
* This method will freeze the SSLContext if one is provided;
* however, session management is still allowed in the frozen SSLContext.
*
* == Support for IO-like objects
*
* Support for IO-like objects was added in version 3.3 and is experimental.
*
* As of version 3.3, SSLSocket uses the following methods:
*
* - <tt>write_nonblock</tt> with the <tt>exception: false</tt> option
* - <tt>read_nonblock</tt> with the <tt>exception: false</tt> option
* - <tt>wait_readable</tt>
* - <tt>wait_writable</tt>
* - <tt>flush</tt>
* - <tt>close</tt>
* - <tt>closed?</tt>
*
* Note that future versions may require additional methods to be implemented.
*/
static VALUE
ossl_ssl_initialize(int argc, VALUE *argv, VALUE self)
Expand All @@ -1630,9 +1656,18 @@ ossl_ssl_initialize(int argc, VALUE *argv, VALUE self)
rb_ivar_set(self, id_i_context, v_ctx);
ossl_sslctx_setup(v_ctx);

if (rb_respond_to(io, rb_intern("nonblock=")))
rb_funcall(io, rb_intern("nonblock="), 1, Qtrue);
Check_Type(io, T_FILE);
if (is_real_socket(io)) {
rb_io_t *fptr;
GetOpenFile(io, fptr);
rb_io_set_nonblock(fptr);
}
else {
// Not meant to be a comprehensive check
if (!rb_respond_to(io, rb_intern("read_nonblock")) ||
!rb_respond_to(io, rb_intern("write_nonblock")))
rb_raise(rb_eTypeError, "io must be a real IO object or an IO-like "
"object that responds to read_nonblock and write_nonblock");
}
rb_ivar_set(self, id_i_io, io);

ssl = SSL_new(ctx);
Expand Down Expand Up @@ -1664,27 +1699,68 @@ ossl_ssl_setup(VALUE self)
{
VALUE io;
SSL *ssl;
rb_io_t *fptr;

GetSSL(self, ssl);
if (ssl_started(ssl))
return Qtrue;

io = rb_attr_get(self, id_i_io);
GetOpenFile(io, fptr);
rb_io_check_readable(fptr);
rb_io_check_writable(fptr);
if (!SSL_set_fd(ssl, TO_SOCKET(rb_io_descriptor(io))))
ossl_raise(eSSLError, "SSL_set_fd");
if (is_real_socket(io)) {
rb_io_t *fptr;
GetOpenFile(io, fptr);
rb_io_check_readable(fptr);
rb_io_check_writable(fptr);
if (!SSL_set_fd(ssl, TO_SOCKET(rb_io_descriptor(io))))
ossl_raise(eSSLError, "SSL_set_fd");
}
else {
VALUE bobj = ossl_bio_new(io);
rb_ivar_set(self, id_bio, bobj);

BIO *bio = ossl_bio_get(bobj);
if (!BIO_up_ref(bio))
ossl_raise(eSSLError, "BIO_up_ref");
SSL_set_bio(ssl, bio, bio);
}

return Qtrue;
}

static void
check_bio_error(SSL *ssl, VALUE bobj, int ret)
{
// Socket BIO -> nothing to do
if (NIL_P(bobj)) {
#ifdef _WIN32
#define ssl_get_error(ssl, ret) (errno = rb_w32_map_errno(WSAGetLastError()), SSL_get_error((ssl), (ret)))
#else
#define ssl_get_error(ssl, ret) SSL_get_error((ssl), (ret))
errno = rb_w32_map_errno(WSAGetLastError());
#endif
return;
}

int state = ossl_bio_state(bobj);
if (!state) {
errno = 0;
return;
}

/*
* Operation may succeed while the underlying socket reports an error in
* some cases. For example, when TLS 1.3 server tries to send a
* NewSessionTicket on a closed socket (IOW, when the client disconnects
* right after finishing a handshake).
*
* In OpenSSL 3.4.0, ssl/statem/statem_srvr.c conn_is_closed() ignores
* EPIPE and ECONNRESET.
*
* We can't map the exception to a specific errno
*/
if (rb_obj_is_kind_of(rb_errinfo(), rb_eSystemCallError) && ret > 0) {
rb_set_errinfo(Qnil);
return;
}
ossl_clear_error();
rb_jump_tag(state);
}

static void
write_would_block(int nonblock)
Expand Down Expand Up @@ -1724,6 +1800,11 @@ no_exception_p(VALUE opts)
static void
io_wait_writable(VALUE io)
{
if (!is_real_socket(io)) {
if (!RTEST(rb_funcallv(io, rb_intern("wait_writable"), 0, NULL)))
rb_raise(IO_TIMEOUT_ERROR, "Timed out while waiting to become writable!");
return;
}
#ifdef HAVE_RB_IO_MAYBE_WAIT
if (!rb_io_maybe_wait_writable(errno, io, RUBY_IO_TIMEOUT_DEFAULT)) {
rb_raise(IO_TIMEOUT_ERROR, "Timed out while waiting to become writable!");
Expand All @@ -1738,6 +1819,11 @@ io_wait_writable(VALUE io)
static void
io_wait_readable(VALUE io)
{
if (!is_real_socket(io)) {
if (!RTEST(rb_funcallv(io, rb_intern("wait_readable"), 0, NULL)))
rb_raise(IO_TIMEOUT_ERROR, "Timed out while waiting to become readable!");
return;
}
#ifdef HAVE_RB_IO_MAYBE_WAIT
if (!rb_io_maybe_wait_readable(errno, io, RUBY_IO_TIMEOUT_DEFAULT)) {
rb_raise(IO_TIMEOUT_ERROR, "Timed out while waiting to become readable!");
Expand All @@ -1761,9 +1847,12 @@ ossl_start_ssl(VALUE self, int (*func)(SSL *), const char *funcname, VALUE opts)

GetSSL(self, ssl);

VALUE io = rb_attr_get(self, id_i_io);
VALUE io = rb_attr_get(self, id_i_io),
bobj = rb_attr_get(self, id_bio);

for (;;) {
ret = func(ssl);
check_bio_error(ssl, bobj, ret);

cb_state = rb_attr_get(self, ID_callback_state);
if (!NIL_P(cb_state)) {
Expand All @@ -1775,7 +1864,7 @@ ossl_start_ssl(VALUE self, int (*func)(SSL *), const char *funcname, VALUE opts)
if (ret > 0)
break;

switch ((ret2 = ssl_get_error(ssl, ret))) {
switch ((ret2 = SSL_get_error(ssl, ret))) {
case SSL_ERROR_WANT_WRITE:
if (no_exception_p(opts)) { return sym_wait_writable; }
write_would_block(nonblock);
Expand Down Expand Up @@ -1953,11 +2042,13 @@ ossl_ssl_read_internal(int argc, VALUE *argv, VALUE self, int nonblock)
return str;
}

VALUE io = rb_attr_get(self, id_i_io);
VALUE io = rb_attr_get(self, id_i_io),
bobj = rb_attr_get(self, id_bio);

rb_str_locktmp(str);
for (;;) {
int nread = SSL_read(ssl, RSTRING_PTR(str), ilen);
check_bio_error(ssl, bobj, nread);

cb_state = rb_attr_get(self, ID_callback_state);
if (!NIL_P(cb_state)) {
Expand All @@ -1966,7 +2057,7 @@ ossl_ssl_read_internal(int argc, VALUE *argv, VALUE self, int nonblock)
rb_jump_tag(NUM2INT(cb_state));
}

switch (ssl_get_error(ssl, nread)) {
switch (SSL_get_error(ssl, nread)) {
case SSL_ERROR_NONE:
rb_str_unlocktmp(str);
rb_str_set_len(str, nread);
Expand Down Expand Up @@ -2065,15 +2156,17 @@ ossl_ssl_write_internal_safe(VALUE _args)
if (!ssl_started(ssl))
rb_raise(eSSLError, "SSL session is not started yet");

VALUE io = rb_attr_get(self, id_i_io);

/* SSL_write(3ssl) manpage states num == 0 is undefined */
num = RSTRING_LENINT(str);
if (num == 0)
return INT2FIX(0);

VALUE io = rb_attr_get(self, id_i_io),
bobj = rb_attr_get(self, id_bio);

for (;;) {
int nwritten = SSL_write(ssl, RSTRING_PTR(str), num);
check_bio_error(ssl, bobj, nwritten);

cb_state = rb_attr_get(self, ID_callback_state);
if (!NIL_P(cb_state)) {
Expand All @@ -2082,7 +2175,7 @@ ossl_ssl_write_internal_safe(VALUE _args)
rb_jump_tag(NUM2INT(cb_state));
}

switch (ssl_get_error(ssl, nwritten)) {
switch (SSL_get_error(ssl, nwritten)) {
case SSL_ERROR_NONE:
return INT2NUM(nwritten);
case SSL_ERROR_WANT_WRITE:
Expand Down Expand Up @@ -2182,7 +2275,14 @@ ossl_ssl_stop(VALUE self)
GetSSL(self, ssl);
if (!ssl_started(ssl))
return Qnil;

ret = SSL_shutdown(ssl);

/* XXX: Suppressing errors from the underlying socket */
VALUE bobj = rb_attr_get(self, id_bio);
if (!NIL_P(bobj) && ossl_bio_state(bobj))
rb_set_errinfo(Qnil);

if (ret == 1) /* Have already received close_notify */
return Qnil;
if (ret == 0) /* Sent close_notify, but we don't wait for reply */
Expand Down Expand Up @@ -3164,6 +3264,7 @@ Init_ossl_ssl(void)
id_tmp_dh_callback = rb_intern_const("tmp_dh_callback");
id_npn_protocols_encoded = rb_intern_const("npn_protocols_encoded");
id_each = rb_intern_const("each");
id_bio = rb_intern_const("bio");

#define DefIVarID(name) do \
id_i_##name = rb_intern_const("@"#name); while (0)
Expand Down
43 changes: 43 additions & 0 deletions test/openssl/test_pair.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ def create_tcp_client(host, port)
end
end

module OpenSSL::SSLPairIOish
include OpenSSL::SSLPairM

def create_tcp_server(host, port)
Addrinfo.tcp(host, port).listen
end

class TCPSocketWrapper
def initialize(io) @io = io end
def read_nonblock(*args, **kwargs) @io.read_nonblock(*args, **kwargs) end
def write_nonblock(*args, **kwargs) @io.write_nonblock(*args, **kwargs) end
def wait_readable() @io.wait_readable end
def wait_writable() @io.wait_writable end
def flush() @io.flush end
def close() @io.close end
def closed?() @io.closed? end

# Only used within test_pair.rb
def write(*args) @io.write(*args) end
end

def create_tcp_client(host, port)
TCPSocketWrapper.new(Addrinfo.tcp(host, port).connect)
end
end

module OpenSSL::TestEOF1M
def open_file(content)
ssl_pair { |s1, s2|
Expand Down Expand Up @@ -518,6 +544,12 @@ class OpenSSL::TestEOF1LowlevelSocket < OpenSSL::TestCase
include OpenSSL::TestEOF1M
end

class OpenSSL::TestEOF1IOish < OpenSSL::TestCase
include OpenSSL::TestEOF
include OpenSSL::SSLPairIOish
include OpenSSL::TestEOF1M
end

class OpenSSL::TestEOF2 < OpenSSL::TestCase
include OpenSSL::TestEOF
include OpenSSL::SSLPair
Expand All @@ -530,6 +562,12 @@ class OpenSSL::TestEOF2LowlevelSocket < OpenSSL::TestCase
include OpenSSL::TestEOF2M
end

class OpenSSL::TestEOF2IOish < OpenSSL::TestCase
include OpenSSL::TestEOF
include OpenSSL::SSLPairIOish
include OpenSSL::TestEOF2M
end

class OpenSSL::TestPair < OpenSSL::TestCase
include OpenSSL::SSLPair
include OpenSSL::TestPairM
Expand All @@ -540,4 +578,9 @@ class OpenSSL::TestPairLowlevelSocket < OpenSSL::TestCase
include OpenSSL::TestPairM
end

class OpenSSL::TestPairIOish < OpenSSL::TestCase
include OpenSSL::SSLPairIOish
include OpenSSL::TestPairM
end

end
Loading

0 comments on commit 0b0f651

Please sign in to comment.