Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Only set min_version on OpenSSL < 1.1.0 #710

Merged
merged 1 commit into from Jan 17, 2024

Conversation

ekohl
Copy link
Contributor

@ekohl ekohl commented Jan 5, 2024

Both Red Hat and Debian-like systems configure the minimum TLS version to be 1.2 by default, but allow users to change this via configs.

On Red Hat and derivatives this happens via crypto-policies, which in writes settings in /etc/crypto-policies/back-ends/opensslcnf.config. Most notably, it sets TLS.MinProtocol there. For Debian there's MinProtocol in /etc/ssl/openssl.cnf. Both default to TLSv1.2, which is considered a secure default.

In constrast, the SSLContext has a hard coded OpenSSL::SSL::TLS1_VERSION for min_version. TLS 1.0 and 1.1 are considered insecure. By always setting this in the default parameters, the system wide default can't be respected, even if a developer wants to.

This takes the approach that's also done for ciphers: it's only set for OpenSSL < 1.1.0.

Fixes #709

@ekohl
Copy link
Contributor Author

ekohl commented Jan 8, 2024

I don't know if the Windows test failure is related.

For the record, this is how I verified this. I first created some self signed certificates and an Apache config on CentOS Stream 8 that provided a vhost with only a single TLS version enabled:

<VirtualHost *:443>
    ServerName tls-10.example.com
    DocumentRoot /var/www/html

    SSLEngine on
    SSLProtocol -all +TLSv1
    SSLCertificateFile      "/etc/ssl/certs/host.crt"
    SSLCertificateKeyFile   "/etc/ssl/certs/host.key"
</VirtualHost>

<VirtualHost *:443>
    ServerName tls-11.example.com
    DocumentRoot /var/www/html

    SSLEngine on
    SSLProtocol -all +TLSv1.1
    SSLCertificateFile      "/etc/ssl/certs/host.crt"
    SSLCertificateKeyFile   "/etc/ssl/certs/host.key"
</VirtualHost>

<VirtualHost *:443>
    ServerName tls-12.example.com
    DocumentRoot /var/www/html

    SSLEngine on
    SSLProtocol -all +TLSv1.2
    SSLCertificateFile      "/etc/ssl/certs/host.crt"
    SSLCertificateKeyFile   "/etc/ssl/certs/host.key"
</VirtualHost>

<VirtualHost *:443>
    ServerName tls-13.example.com
    DocumentRoot /var/www/html

    SSLEngine on
    SSLProtocol -all +TLSv1.3
    SSLCertificateFile      "/etc/ssl/certs/host.crt"
    SSLCertificateKeyFile   "/etc/ssl/certs/host.key"
</VirtualHost>

Then I used a minimal script to retrieve the content:

require 'net/http'

domains = [
        'tls-10.example.com',
        'tls-11.example.com',
        'tls-12.example.com',
        'tls-13.example.com',
]

ca_file = '/etc/ssl/certs/cacert.crt'

domains.each do |domain|
        uri = URI("https://#{domain}")
        begin
                Net::HTTP.start(domain, 443, use_ssl: true, ca_file: ca_file) do |http|
                        http.get(uri)
                end
        rescue StandardError => e
                puts "Failed to load from #{domain}: #{e}"
        else
                puts "Loaded from #{domain}"
        end
end

By default this loads from all 4 domains. When I manually patched /usr/share/ruby/openssl/ssl.rb it only loaded TLS 1.2+, just like /etc/crypto-policies/back-ends/opensslcnf.config says. I then modified /etc/crypto-policies/back-ends/opensslcnf.config and observed it matched.

# grep '^TLS.Min' /etc/crypto-policies/back-ends/opensslcnf.config
TLS.MinProtocol = TLSv1.2
# ruby test.rb
Failed to load from tls-10.example.com: SSL_connect returned=1 errno=0 state=error: tlsv1 alert protocol version
Failed to load from tls-11.example.com: SSL_connect returned=1 errno=0 state=error: tlsv1 alert protocol version
Loaded from tls-12.example.com
Loaded from tls-13.example.com
# vi /etc/crypto-policies/back-ends/opensslcnf.config
# grep '^TLS.Min' /etc/crypto-policies/back-ends/opensslcnf.config
TLS.MinProtocol = TLSv1.0
# ruby test.rb
Loaded from tls-10.example.com
Loaded from tls-11.example.com
Loaded from tls-12.example.com
Loaded from tls-13.example.com
# vi /etc/crypto-policies/back-ends/opensslcnf.config
# ruby test.rb
Failed to load from tls-10.example.com: SSL_connect returned=1 errno=0 state=error: tlsv1 alert protocol version
Failed to load from tls-11.example.com: SSL_connect returned=1 errno=0 state=error: tlsv1 alert protocol version
Failed to load from tls-12.example.com: SSL_connect returned=1 errno=0 state=error: tlsv1 alert protocol version
Loaded from tls-13.example.com

Copy link
Member

@rhenium rhenium left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems good to me. This shouldn't re-enable SSL 3.0 since

@junaruga
Copy link
Member

junaruga commented Jan 8, 2024

Is it better for us to write a unit test for this change?

@ekohl
Copy link
Contributor Author

ekohl commented Jan 9, 2024

@rhenium thanks for that context. I hadn't considered that, but glad to see my implementation works with those in mind.

@junaruga how would you write such a unit test? Test out set_params? I can add something like this:

diff --git a/test/openssl/test_ssl.rb b/test/openssl/test_ssl.rb
index 07dc9a343cef..8efc48ad6dc9 100644
--- a/test/openssl/test_ssl.rb
+++ b/test/openssl/test_ssl.rb
@@ -554,6 +554,15 @@ class OpenSSL::TestSSL < OpenSSL::SSLTestCase
     assert_equal 0, ctx.options & OpenSSL::SSL::OP_DONT_INSERT_EMPTY_FRAGMENTS
     assert_equal OpenSSL::SSL::OP_NO_COMPRESSION,
                  ctx.options & OpenSSL::SSL::OP_NO_COMPRESSION
+
+    if (OpenSSL::OPENSSL_VERSION.start_with?("OpenSSL") &&
+         OpenSSL::OPENSSL_VERSION_NUMBER >= 0x10100000)
+      # The cached value should not exist
+      assert_raise(NoMethodError) { ctx.send(:@min_proto_version) }
+    else
+      # This is not publicly exposed, but read the cached version
+      assert_equal OpenSSL::SSL::TLS1_VERSION, ctx.send(:@min_proto_version)
+    end
   end
 
   def test_post_connect_check_with_anon_ciphers

Would that address your concerns? I always question reusing the same version detection logic, because it's so fragile.

@junaruga
Copy link
Member

junaruga commented Jan 9, 2024

@ekohl good question!

Nowadays we tend to separate the tests to small bits rater than adding the conditional assertions in one test. I wished that we could find a better way rater than checking the @min_proto_version. But yeah, as you suggested, I see that using the @min_proto_version seems the best way for now.

Below is my suggestion. @rhenium is the decision maker. What do you think?

diff --git a/test/openssl/test_ssl.rb b/test/openssl/test_ssl.rb
index 07dc9a3..0f0b557 100644
--- a/test/openssl/test_ssl.rb
+++ b/test/openssl/test_ssl.rb
@@ -556,6 +556,29 @@ class OpenSSL::TestSSL < OpenSSL::SSLTestCase
                  ctx.options & OpenSSL::SSL::OP_NO_COMPRESSION
   end

+  def test_sslctx_set_params_no_min_version_on_greater_than_equal_openssl_11
+    omit 'Omit in OpenSSL 1.0 or eariler versions' unless openssl?(1, 1, 0)
+    omit 'Omit in LibreSSL' if libressl?
+
+    ctx = OpenSSL::SSL::SSLContext.new
+    ctx.set_params
+
+    # The cached value should not exist.
+    assert_raise(NoMethodError) { ctx.send(:@min_proto_version) }
+  end
+
+  def test_sslctx_set_params_min_version_on_less_than_openssl_11_or_libressl
+    if openssl?(1, 1, 0)
+      omit 'Omit in OpenSSL 1.1 or later versions'
+    end
+
+    ctx = OpenSSL::SSL::SSLContext.new
+    ctx.set_params
+
+    # This is not publicly exposed, but read the cached version.
+    assert_equal OpenSSL::SSL::TLS1_VERSION, ctx.send(:@min_proto_version)
+  end
+
   def test_post_connect_check_with_anon_ciphers
     ctx_proc = -> ctx {
       ctx.ssl_version = :TLSv1_2

@ekohl
Copy link
Contributor Author

ekohl commented Jan 9, 2024

Nowadays we tend to separate the tests to small bits rater than adding the conditional assertions in one test.

Actually a very good practice and I completely understand you don't immediately refactor your entire test suite.

I've pushed your tests as an additional commit.

@junaruga
Copy link
Member

@ekohl Sorry. Seeing the CI result: https://github.com/ruby/openssl/actions/runs/7464356851?pr=710, I forget considering the LibreSSL cases. I updated my proposed patch above adding the logic. I don't know why the OpenSSL 1.0.2u case is failing with NoMethodError.

And windows-latest (ruby) 3.3 case, it seems the OpenSSL::Provider.load("legacy") failing to load the legacy provider. I don't why it only happens in the case.
https://github.com/ruby/openssl/actions/runs/7464356851/job/20331465606?pr=710#step:9:637

@junaruga
Copy link
Member

I filed the windows-latest (ruby) 3.3 case's issue on #711. We can ignore the issue on this PR as it is not related to this PR.

@ekohl
Copy link
Contributor Author

ekohl commented Jan 10, 2024

I'm looking at the code and test logic.

I assume that OpenSSL::OPENSSL_VERSION doesn't start with OpenSSL so based on that I'd think it sets min_version (and ciphers) for LibreSSL. But then why does the test results say it doesn't set min_version for LibreSSL?

@junaruga
Copy link
Member

I'm looking at the code and test logic.

I assume that OpenSSL::OPENSSL_VERSION doesn't start with OpenSSL so based on that I'd think it sets min_version (and ciphers) for LibreSSL. But then why does the test results say it doesn't set min_version for LibreSSL?

We are printing some constant values before running the tests. It seems the OpenSSL::OPENSSL_VERSION created from the OPENSSL_VERSION macro starts with OpenSSL in my impression.

https://github.com/ruby/openssl/actions/runs/7464356851/job/20331455533?pr=710#step:12:34

OpenSSL::OPENSSL_VERSION: OpenSSL 1.0.2u  20 Dec 2019
OpenSSL::OPENSSL_LIBRARY_VERSION: OpenSSL 1.0.2u  20 Dec 2019
OpenSSL::OPENSSL_VERSION_NUMBER: 1000215f

@junaruga
Copy link
Member

junaruga commented Jan 10, 2024

Sorry I updated my proposal again.
And in OpenSSL 1.0 and Libre cases, the tests are failing with "NoMethodError: undefined method `@min_proto_version'".

The CI result is below as a refernece.
https://github.com/junaruga/ruby-openssl/actions/runs/7478375171/job/20353212727

@junaruga
Copy link
Member

junaruga commented Jan 11, 2024

@ekohl Could you check this issue by installing OpenSSL 1.0 or LibreSSL on your environment?

A document to build the Ruby OpenSSL with different version's OpenSSL or LibreSSL is here.

If you want to compile and install OpenSSL 1.0 on Linux, it can be like this.

$ ./config \
  --prefix="/path/to/your_openssl_dir" \
  --libdir=lib \
  shared \
  '-Wl,-rpath,$(LIBRPATH)' \
  -O0 -g3 -ggdb3 -gdwarf-5
$ make -j4
$ make install

@junaruga
Copy link
Member

junaruga commented Jan 11, 2024

You can rebase this PR on the latest master branch. The CI windows-latest 3.3 case's failures were fixed by a workaround.

Both Red Hat and Debian-like systems configure the minimum TLS version
to be 1.2 by default, but allow users to change this via configs.

On Red Hat and derivatives this happens via crypto-policies[1], which in
writes settings in /etc/crypto-policies/back-ends/opensslcnf.config.
Most notably, it sets TLS.MinProtocol there. For Debian there's
MinProtocol in /etc/ssl/openssl.cnf. Both default to TLSv1.2, which is
considered a secure default.

In constrast, the SSLContext has a hard coded OpenSSL::SSL::TLS1_VERSION
for min_version. TLS 1.0 and 1.1 are considered insecure. By always
setting this in the default parameters, the system wide default can't be
respected, even if a developer wants to.

This takes the approach that's also done for ciphers: it's only set for
OpenSSL < 1.1.0.

[1]: https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/security_hardening/using-the-system-wide-cryptographic-policies_security-hardening
@ekohl ekohl force-pushed the dont-set-min_version-by-default branch from 9836dbc to 104d68f Compare January 12, 2024 11:14
@ekohl
Copy link
Contributor Author

ekohl commented Jan 12, 2024

I've only applied the typo fix and omit statements. I haven't installed OpenSSL 1.0 myself yet to see what it does, but perhaps I can find some time for that later.

@rhenium
Copy link
Member

rhenium commented Jan 13, 2024

We already have a test case for this, that #set_params won't allow connecting to SSL 3.0-only server:

def test_set_params_min_version
supported = check_supported_protocol_versions
store = OpenSSL::X509::Store.new
store.add_cert(@ca_cert)
if supported.include?(OpenSSL::SSL::SSL3_VERSION)
# SSLContext#set_params properly disables SSL 3.0 by default
ctx_proc = proc { |ctx|
ctx.min_version = ctx.max_version = OpenSSL::SSL::SSL3_VERSION
}
start_server(ctx_proc: ctx_proc, ignore_listener_error: true) { |port|
ctx = OpenSSL::SSL::SSLContext.new
ctx.set_params(cert_store: store, verify_hostname: false)
assert_handshake_error { server_connect(port, ctx) { } }
}
end
end

Honestly I think this is enough. I could have pushed the merge button already, but I wanted to see what was causing the failure on Windows (you have figured it out - #711).

@ekohl
Copy link
Contributor Author

ekohl commented Jan 13, 2024

Since this is now causing test failures, should I drop that commit then?

@junaruga
Copy link
Member

junaruga commented Jan 13, 2024

Since this is now causing test failures, should I drop that commit then?

Yes, I think that you can drop the 2nd commit! As I don't understand the logic of testing this PR, I may check it later with OpenSSL 3 and 1.0 on my local. But it doesn't block the PR!

@ekohl ekohl force-pushed the dont-set-min_version-by-default branch from 104d68f to ae215a4 Compare January 13, 2024 22:28
@ekohl
Copy link
Contributor Author

ekohl commented Jan 13, 2024

Done

@rhenium rhenium merged commit 559b8ed into ruby:master Jan 17, 2024
49 checks passed
@rhenium
Copy link
Member

rhenium commented Jan 17, 2024

Merged, thank you for the PR!

@ekohl ekohl deleted the dont-set-min_version-by-default branch January 17, 2024 17:11
@junaruga
Copy link
Member

junaruga commented Apr 3, 2024

I have investigated a bit about this PR because I may need to backport this PR's commit to downstream OpenSSL packages. Below is the summary. I am sure you guys already know about the information. But just in case for someone who is interested in the topic.

Fixed openssl gem versions

First, the actual merged commit on the master branch is ae215a4. This commit is not included in any released openssl gem versions yet. while the openssl gem current latest released version is 3.2.0. So, there are no fixed openssl gem versions yet.

Affected cases

This issue is that calling the OpenSSL::SSL::SSLContext#set_params would override the value of the TLS.MinProtocol in the OpenSSL config file. This issue can happen on the OpenSSL with the setting such as a Fedora's downstream OpenSSL RPM package referring to the setting (/etc/crypto-policies/back-ends/opensslcnf.config - TLS.MinProtocol = TLSv1.2) included in the RPM package named "crypto-policies". Below is an example on my local machine Fedora 39.

$ rpm -qf /etc/crypto-policies/back-ends/opensslcnf.config
crypto-policies-20231204-1.git1e3a2e4.fc39.noarch

$ grep '^TLS\.MinProtocol' /etc/crypto-policies/back-ends/opensslcnf.config
TLS.MinProtocol = TLSv1.2

Reproducing the issue

If we confirm the value of the set min_version, we need to run a HTTPS (SSL) server as this example. Because Ruby OpenSSL doesn't provide the readable attributes to check the actual values. I assume that this is due to a better security.

However, if we just check if a Ruby OpenSSL includes this commit in the code level, the reproducing script is as follows. the script refers to the cached variable @min_proto_version at the Ruby language level, as it was mentioned somewhere,

$ cat test.rb
require 'openssl'

ctx = OpenSSL::SSL::SSLContext.new
ctx.set_params
min_version = ctx.instance_variable_get(:@min_proto_version)
p min_version
puts "min_version hex=#{min_version&.to_s(16)}"

If a Ruby OpenSSL doesn't include this commit, the result is below. The 769 is the decimal number value of the OpenSSL::SSL::TLS1_VERSION. The 301 is the hexdecimal number value of it.

$ ruby -I lib test.rb
769
min_version hex=301

If a Ruby OpenSSL includes this commit, the result is below. The min_version to be propagated to a SSL server is empty. That means the TLS.MinProtocol in the OpenSSL's config file is used if it is set.

$ ruby -I lib test.rb
nil
min_version hex=

Note that OpenSSL::SSL::TLS1_VERSION and other OpenSSL::SSL::SOMETHING_VERSION constants are to get the name's macro value defined in OpenSSL.

openssl/ext/openssl/ossl_ssl.c

Lines 3111 to 3128 in d3d857c

/*
* SSL/TLS version constants. Used by SSLContext#min_version= and
* #max_version=
*/
/* SSL 2.0 */
rb_define_const(mSSL, "SSL2_VERSION", INT2NUM(SSL2_VERSION));
/* SSL 3.0 */
rb_define_const(mSSL, "SSL3_VERSION", INT2NUM(SSL3_VERSION));
/* TLS 1.0 */
rb_define_const(mSSL, "TLS1_VERSION", INT2NUM(TLS1_VERSION));
/* TLS 1.1 */
rb_define_const(mSSL, "TLS1_1_VERSION", INT2NUM(TLS1_1_VERSION));
/* TLS 1.2 */
rb_define_const(mSSL, "TLS1_2_VERSION", INT2NUM(TLS1_2_VERSION));
#ifdef TLS1_3_VERSION /* OpenSSL 1.1.1 */
/* TLS 1.3 */
rb_define_const(mSSL, "TLS1_3_VERSION", INT2NUM(TLS1_3_VERSION));
#endif

And the macros are defined in the OpenSSL's code below.

https://github.com/openssl/openssl/blob/882a387d0dc12afe8612c4d3f6b9cae5c04611d7/include/openssl/prov_ssl.h#L22-L30

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Respect system wide minimum TLS version
4 participants