diff --git a/ext/openssl/extconf.rb b/ext/openssl/extconf.rb index dcfc7e43c..7168af55f 100644 --- a/ext/openssl/extconf.rb +++ b/ext/openssl/extconf.rb @@ -160,6 +160,7 @@ def find_openssl_library # added in OpenSSL 1.1.1, currently not in LibreSSL have_func("OPENSSL_sk_new_reserve(NULL, 0)", stack_h) +have_func("SSL_CTX_set_client_hello_cb(NULL, NULL, NULL)", ssl_h) # added in 3.0.0 have_func("SSL_CTX_set0_tmp_dh_pkey(NULL, NULL)", ssl_h) diff --git a/ext/openssl/ossl_ssl.c b/ext/openssl/ossl_ssl.c index 825c2a17e..dfa45afcc 100644 --- a/ext/openssl/ossl_ssl.c +++ b/ext/openssl/ossl_ssl.c @@ -47,7 +47,8 @@ static ID id_i_cert_store, id_i_ca_file, id_i_ca_path, id_i_verify_mode, id_i_session_id_context, id_i_session_get_cb, id_i_session_new_cb, id_i_session_remove_cb, id_i_npn_select_cb, id_i_npn_protocols, id_i_alpn_select_cb, id_i_alpn_protocols, id_i_servername_cb, - id_i_verify_hostname, id_i_keylog_cb, id_i_tmp_dh_callback; + id_i_client_hello_cb, id_i_verify_hostname, id_i_keylog_cb, + id_i_tmp_dh_callback; static ID id_i_io, id_i_context, id_i_hostname, id_i_sync_close; static int ossl_ssl_ex_ptr_idx; @@ -441,16 +442,27 @@ ossl_sslctx_add_extra_chain_cert_i(RB_BLOCK_CALL_FUNC_ARGLIST(i, arg)) static VALUE ossl_sslctx_setup(VALUE self); +struct ossl_call_servername_cb_args { + SSL *ssl; + SSL_CTX *ctx; +}; + static VALUE ossl_call_servername_cb(VALUE arg) { - SSL *ssl = (void *)arg; + struct ossl_call_servername_cb_args *args = (void *)arg; + SSL *ssl = args->ssl; const char *servername = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name); if (!servername) return Qnil; VALUE ssl_obj = (VALUE)SSL_get_ex_data(ssl, ossl_ssl_ex_ptr_idx); - VALUE sslctx_obj = rb_attr_get(ssl_obj, id_i_context); + /* + * OpenSSL may invoke the callback of the original SSL_CTX after + * SSL_set_SSL_CTX(), so use the SSLContext that registered it + */ + VALUE sslctx_obj = (VALUE)SSL_CTX_get_ex_data(args->ctx, + ossl_sslctx_ex_ptr_idx); VALUE cb = rb_attr_get(sslctx_obj, id_i_servername_cb); VALUE ary = rb_assoc_new(ssl_obj, rb_str_new_cstr(servername)); @@ -473,9 +485,10 @@ ossl_call_servername_cb(VALUE arg) static int ssl_servername_cb(SSL *ssl, int *ad, void *arg) { + struct ossl_call_servername_cb_args args = { ssl, arg }; int state; - rb_protect(ossl_call_servername_cb, (VALUE)ssl, &state); + rb_protect(ossl_call_servername_cb, (VALUE)&args, &state); if (state) { VALUE ssl_obj = (VALUE)SSL_get_ex_data(ssl, ossl_ssl_ex_ptr_idx); rb_ivar_set(ssl_obj, ID_callback_state, INT2NUM(state)); @@ -485,6 +498,115 @@ ssl_servername_cb(SSL *ssl, int *ad, void *arg) return SSL_TLSEXT_ERR_OK; } +#ifdef HAVE_SSL_CTX_SET_CLIENT_HELLO_CB +static VALUE cClientHello; + +/* + * The SSL object is borrowed from the SSLSocket and is only valid while + * the client_hello_cb is running. + */ +static const rb_data_type_t ossl_client_hello_type = { + .wrap_struct_name = "OpenSSL/SSL/ClientHello", + .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED, +}; + +static inline SSL * +GetClientHello(VALUE obj) +{ + SSL *ssl; + + TypedData_Get_Struct(obj, SSL, &ossl_client_hello_type, ssl); + if (!ssl) + ossl_raise(rb_eRuntimeError, "ClientHello is out of scope!"); + return ssl; +} + +/* + * call-seq: + * client_hello.get_extension(type) -> String or nil + * + * Returns the raw data of the extension specified by the extension type + * number _type_, or +nil+ if the ClientHello doesn't contain it. + * + * See SSL_client_hello_get0_ext(3). + */ +static VALUE +ossl_client_hello_get_extension(VALUE self, VALUE type) +{ + SSL *ssl; + const unsigned char *out; + size_t outlen; + + ssl = GetClientHello(self); + if (!SSL_client_hello_get0_ext(ssl, NUM2UINT(type), &out, &outlen)) + return Qnil; + return rb_str_new((const char *)out, (long)outlen); +} + +struct ossl_call_client_hello_cb_args { + SSL *ssl; + VALUE client_hello; +}; + +static VALUE +ossl_call_client_hello_cb(VALUE arg) +{ + struct ossl_call_client_hello_cb_args *args = (void *)arg; + SSL *ssl = args->ssl; + VALUE ssl_obj = (VALUE)SSL_get_ex_data(ssl, ossl_ssl_ex_ptr_idx); + VALUE sslctx_obj = rb_attr_get(ssl_obj, id_i_context); + VALUE cb = rb_attr_get(sslctx_obj, id_i_client_hello_cb); + + args->client_hello = + TypedData_Wrap_Struct(cClientHello, &ossl_client_hello_type, ssl); + VALUE ret_obj = rb_funcall(cb, id_call, 2, ssl_obj, args->client_hello); + if (rb_obj_is_kind_of(ret_obj, cSSLContext)) { + SSL_CTX *ctx2; + ossl_sslctx_setup(ret_obj); + GetSSLCTX(ret_obj, ctx2); + if (!SSL_set_SSL_CTX(ssl, ctx2)) + ossl_raise(eSSLError, "SSL_set_SSL_CTX"); + /* SSL_set_SSL_CTX() doesn't update these settings */ + SSL_set_verify(ssl, + SSL_CTX_get_verify_mode(ctx2), + SSL_CTX_get_verify_callback(ctx2)); + SSL_set_verify_depth(ssl, SSL_CTX_get_verify_depth(ctx2)); + SSL_clear_options(ssl, + SSL_get_options(ssl) & ~SSL_CTX_get_options(ctx2)); + SSL_set_options(ssl, SSL_CTX_get_options(ctx2)); + if (!SSL_set_min_proto_version(ssl, SSL_CTX_get_min_proto_version(ctx2))) + ossl_raise(eSSLError, "SSL_set_min_proto_version"); + if (!SSL_set_max_proto_version(ssl, SSL_CTX_get_max_proto_version(ctx2))) + ossl_raise(eSSLError, "SSL_set_max_proto_version"); + rb_ivar_set(ssl_obj, id_i_context, ret_obj); + } else if (!NIL_P(ret_obj)) { + ossl_raise(rb_eArgError, "client_hello_cb must return an " + "OpenSSL::SSL::SSLContext object or nil"); + } + + return Qnil; +} + +static int +ssl_client_hello_cb(SSL *ssl, int *al, void *arg) +{ + struct ossl_call_client_hello_cb_args args = { ssl, Qnil }; + int state; + + rb_protect(ossl_call_client_hello_cb, (VALUE)&args, &state); + if (!NIL_P(args.client_hello)) + RTYPEDDATA_DATA(args.client_hello) = NULL; + if (state) { + VALUE ssl_obj = (VALUE)SSL_get_ex_data(ssl, ossl_ssl_ex_ptr_idx); + rb_ivar_set(ssl_obj, ID_callback_state, INT2NUM(state)); + *al = SSL_AD_INTERNAL_ERROR; + return SSL_CLIENT_HELLO_ERROR; + } + + return SSL_CLIENT_HELLO_SUCCESS; +} +#endif + static void ssl_renegotiation_cb(const SSL *ssl) { @@ -841,6 +963,16 @@ ossl_sslctx_setup(VALUE self) OSSL_Debug("SSL ALPN select callback added"); } + if (RTEST(rb_attr_get(self, id_i_client_hello_cb))) { +#ifdef HAVE_SSL_CTX_SET_CLIENT_HELLO_CB + SSL_CTX_set_client_hello_cb(ctx, ssl_client_hello_cb, NULL); + OSSL_Debug("SSL client hello callback added"); +#else + rb_raise(rb_eNotImpError, "client_hello_cb is not supported " + "by the linked SSL library"); +#endif + } + rb_obj_freeze(self); val = rb_attr_get(self, id_i_session_id_context); @@ -868,6 +1000,7 @@ ossl_sslctx_setup(VALUE self) val = rb_attr_get(self, id_i_servername_cb); if (!NIL_P(val)) { SSL_CTX_set_tlsext_servername_callback(ctx, ssl_servername_cb); + SSL_CTX_set_tlsext_servername_arg(ctx, ctx); OSSL_Debug("SSL TLSEXT servername callback added"); } @@ -2944,6 +3077,19 @@ Init_ossl_ssl(void) rb_define_method(cSSLContext, "options", ossl_sslctx_get_options, 0); rb_define_method(cSSLContext, "options=", ossl_sslctx_set_options, 1); +#ifdef HAVE_SSL_CTX_SET_CLIENT_HELLO_CB + /* + * Document-class: OpenSSL::SSL::ClientHello + * + * A ClientHello message received by the server. It is passed to + * SSLContext#client_hello_cb and is only valid while the callback is + * running. + */ + cClientHello = rb_define_class_under(mSSL, "ClientHello", rb_cObject); + rb_undef_alloc_func(cClientHello); + rb_define_method(cClientHello, "get_extension", ossl_client_hello_get_extension, 1); +#endif + /* * Document-class: OpenSSL::SSL::SSLSocket */ @@ -3165,6 +3311,7 @@ Init_ossl_ssl(void) DefIVarID(alpn_protocols); DefIVarID(alpn_select_cb); DefIVarID(servername_cb); + DefIVarID(client_hello_cb); DefIVarID(verify_hostname); DefIVarID(keylog_cb); DefIVarID(tmp_dh_callback); diff --git a/lib/openssl/ssl.rb b/lib/openssl/ssl.rb index 85a91dee2..8165050c9 100644 --- a/lib/openssl/ssl.rb +++ b/lib/openssl/ssl.rb @@ -82,6 +82,22 @@ class SSLContext # callback must return an SSLContext for the server name or nil. attr_accessor :servername_cb + # A callback invoked when the server receives a ClientHello message, + # before the protocol version and session resumption are decided. + # + # The callback is invoked with an SSLSocket and an + # OpenSSL::SSL::ClientHello. The ClientHello is only valid while the + # callback is running. The callback must return an SSLContext to use + # for the connection or nil to keep using the current one. + # + # #verify_mode, #verify_depth, #options, #min_version= and + # #max_version= of the returned SSLContext take effect. To prevent a + # session established with one SSLContext from being resumed with + # another, set a different #session_id_context to each SSLContext. + # + # This is not supported by LibreSSL. + attr_accessor :client_hello_cb + # call-seq: # SSLContext.new -> ctx # SSLContext.new(:TLSv1) -> ctx diff --git a/test/openssl/test_ssl.rb b/test/openssl/test_ssl.rb index fa220650b..e64f86d47 100644 --- a/test/openssl/test_ssl.rb +++ b/test/openssl/test_ssl.rb @@ -1199,6 +1199,102 @@ def test_servername_cb_raises_an_exception_on_unknown_objects end end + def test_client_hello_cb + omit "LibreSSL doesn't support client_hello_cb" if libressl? + + tls12_ctx = make_server_context + tls12_ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION + # server_name extension (RFC 6066) containing a single host_name + name = "example.org" + ext = [name.bytesize + 3, 0, name.bytesize, name].pack("nCna*") + exts = [] + saved_client_hello = nil + sctx = make_server_context + sctx.min_version = OpenSSL::SSL::TLS1_3_VERSION + sctx.client_hello_cb = lambda { |ssl, client_hello| + saved_client_hello = client_hello + exts << client_hello.get_extension(0) # server_name + exts.last ? tls12_ctx : nil + } + start_server(sctx) do |port| + server_connect(port, hostname: name) { |ssl| + assert_equal "TLSv1.2", ssl.ssl_version + ssl.puts "abc" + assert_equal "abc\n", ssl.gets + } + server_connect(port) { |ssl| + assert_equal "TLSv1.3", ssl.ssl_version + ssl.puts "abc" + assert_equal "abc\n", ssl.gets + } + end + assert_equal [ext, nil], exts + assert_raise_with_message(RuntimeError, /out of scope/) { + saved_client_hello.get_extension(0) + } + end + + def test_client_hello_cb_verify_mode + omit "LibreSSL doesn't support client_hello_cb" if libressl? + + client_auth_ctx = make_server_context + client_auth_ctx.verify_mode = + OpenSSL::SSL::VERIFY_PEER|OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT + sctx = make_server_context + sctx.client_hello_cb = lambda { |ssl, client_hello| client_auth_ctx } + start_server(sctx, ignore_listener_error: true) { |port| + assert_raise(OpenSSL::SSL::SSLError) { + server_connect(port) { |ssl| + ssl.puts("abc") + ssl.gets + } + } + } + end + + def test_client_hello_cb_with_servername_cb + omit "LibreSSL doesn't support client_hello_cb" if libressl? + + called = false + ctx_without_servername_cb = make_server_context + sctx = make_server_context + sctx.client_hello_cb = lambda { |ssl, client_hello| + ctx_without_servername_cb + } + # OpenSSL invokes the servername_cb of sctx instead + sctx.servername_cb = lambda { |args| + called = true + nil + } + start_server(sctx) do |port| + server_connect(port, hostname: "example.org") { |ssl| + ssl.puts "abc" + assert_equal "abc\n", ssl.gets + } + end + assert_equal true, called + end + + def test_client_hello_cb_exception + omit "LibreSSL doesn't support client_hello_cb" if libressl? + + server_proc = proc do |sock| + sctx = make_server_context + sctx.client_hello_cb = lambda { |ssl, client_hello| + raise RuntimeError, "foo" + } + ssl = OpenSSL::SSL::SSLSocket.new(sock, sctx) + assert_raise_with_message(RuntimeError, "foo") { ssl.accept } + end + start_server_proc(server_proc) do |port| + sock = TCPSocket.new("127.0.0.1", port) + ssl = OpenSSL::SSL::SSLSocket.new(sock) + assert_raise(OpenSSL::SSL::SSLError) { ssl.connect } + ensure + sock&.close + end + end + def test_verify_hostname_on_connect exts = [ ["keyUsage", "keyEncipherment,digitalSignature", true], @@ -2446,10 +2542,11 @@ def test_ractor_set_params private - def server_connect(port, ctx = nil) + def server_connect(port, ctx = nil, hostname: nil) sock = TCPSocket.new("127.0.0.1", port) ssl = ctx ? OpenSSL::SSL::SSLSocket.new(sock, ctx) : OpenSSL::SSL::SSLSocket.new(sock) ssl.sync_close = true + ssl.hostname = hostname if hostname ssl.connect yield ssl if block_given? ensure