diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd9df1..82199cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Changes +* Use `OpenSSL::X509::DEFAULT_CERT_FILE` by default on MRI. `SSLConfig#cacert_file=` can select another CA file, including the bundled certificates via `SSLConfig::BUNDLED_CACERT_FILE` ([#480](https://github.com/nahi/httpclient/issues/480)). + ## Change Log ### Changes in 2.10.0 diff --git a/README.md b/README.md index 80e695b..60d7989 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,26 @@ You can install httpclient via rubygems: `gem install httpclient` See [HTTPClient](http://www.rubydoc.info/gems/httpclient/frames) for documentation. You can also check sample/howto.rb how to use APIs. +### TLS trust store + +On MRI, HTTPClient uses `OpenSSL::X509::DEFAULT_CERT_FILE` by default. To +preserve compatibility with previous releases' default CA selection, select the +bundled Mozilla CA certificates explicitly: + +```ruby +client = HTTPClient.new +client.ssl_config.cacert_file = HTTPClient::SSLConfig::BUNDLED_CACERT_FILE +``` + +Assigning `cacert_file` loads that file into a new certificate store, replacing +the current store and resetting existing sessions. You can assign it after a +connection has been made. `load_trust_ca` adds the selected file, if any, to the +current store and resets existing sessions. On JRuby, CA selection defaults to +the JVM trust store and can be overridden with `SSL_CERT_FILE`; assigning +`cacert_file` explicitly selects a certificate file. Use +`client.ssl_config.set_default_paths` on MRI to load OpenSSL's configured +default certificate file and directory. + ### TLS certificate restrictions Blocked public keys are identified by their SPKI SHA-256 hashes in @@ -91,11 +111,10 @@ context. With peer verification enabled, its return value controls certificate verification. The caller is responsible for the custom callback's validation policy. Hostname verification is unchanged. -Note: MRI uses HTTPClient's bundled `cacert.pem` by default, while JRuby uses -the JVM's CA certificates and certificate validation. Secure operation on JRuby -depends on keeping the JVM and its CA certificates up to date. HTTPClient's -additional check may not detect blocked intermediates found only in a local -trust store or obtained through JVM issuer retrieval (such as AIA when enabled). +On JRuby, HTTPClient uses the JVM's CA certificates and validation by default. +Keep the JVM and its CA certificates up to date. The additional blocked-key +check may not detect intermediates found only in a local trust store or obtained +through JVM issuer retrieval (such as AIA when enabled). Remove unused certificates containing blocked public keys from the server's certificate chain configuration. If a certificate containing a blocked public key diff --git a/lib/httpclient/ssl_config.rb b/lib/httpclient/ssl_config.rb index 2994442..5fd5589 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -20,23 +20,20 @@ class HTTPClient # # == Trust Anchor Control # - # SSLConfig loads 'httpclient/cacert.pem' as a trust anchor - # (trusted certificate(s)) with add_trust_ca in initialization time. - # This means that HTTPClient instance trusts some CA certificates by default, - # like Web browsers. 'httpclient/cacert.pem' is downloaded from curl web - # site by the author and included in released package. + # On MRI, HTTPClient uses OpenSSL::X509::DEFAULT_CERT_FILE by default. To + # preserve compatibility with previous releases' default CA selection, + # explicitly select the bundled Mozilla CA certificates: # - # On JRuby, HTTPClient uses Java runtime's trusted CA certificates, not - # cacert.pem by default. You can load cacert.pem by calling - # SSLConfig#load_trust_ca manually like: + # client = HTTPClient.new + # client.ssl_config.cacert_file = HTTPClient::SSLConfig::BUNDLED_CACERT_FILE # - # HTTPClient.new { self.ssl_config.load_trust_ca }.get("https://...") + # Assigning cacert_file loads the selected file into a new certificate store, + # replacing the current store and resetting existing sessions. It can be + # changed after connections have been made. load_trust_ca loads the selected + # file, if any, into the current store, adding to its existing certificates. # - # Secure operation on JRuby depends on keeping the JVM and its CA certificates - # up to date. - # - # You may want to change trust anchor by yourself. Call clear_cert_store - # then add_trust_ca for that purpose. + # On JRuby, CA selection defaults to the JVM's trust store and can be + # overridden with SSL_CERT_FILE. Assign cacert_file to select a file directly. # # == TLS certificate restrictions # @@ -116,6 +113,8 @@ def attr_config(symbol) CIPHERS_DEFAULT = "ALL:!aNULL:!eNULL:!SSLv2" # OpenSSL >1.0.0 default + # Bundled Mozilla CA certificates, available for explicit selection. + BUNDLED_CACERT_FILE = File.join(File.dirname(__FILE__), 'cacert.pem').freeze # Public keys whose SPKI SHA-256 hashes are listed below are considered blocked. # The list covers eight eMudhra intermediate CA public keys whose corresponding @@ -197,9 +196,12 @@ def self.blocked_public_key?(public_key_der) # See ciphers(1) man in OpenSSL for more detail. attr_config :ciphers - # OpenSSL::X509::X509::Store used for verification. You can reset the - # store with clear_cert_store and set the new store with cert_store=. + # OpenSSL::X509::Store used for verification. You can reset the store with + # clear_cert_store and set the new store with cert_store=. attr_reader :cert_store # don't use if you don't know what it is. + # Selected CA certificate file. Defaults to OpenSSL::X509::DEFAULT_CERT_FILE + # on MRI and nil on JRuby, where CA selection is left to the runtime. + attr_reader :cacert_file # For server side configuration. Ignore this. attr_config :client_ca # :nodoc: @@ -214,6 +216,8 @@ def cert_store_items; @cert_store._httpclient_cert_store_items; end def initialize(client) return unless SSLEnabled @client = client + # nil leaves JRuby's default CA selection (JVM store or SSL_CERT_FILE) intact. + @cacert_file = defined?(JRUBY_VERSION) ? nil : X509::DEFAULT_CERT_FILE @cert_store = X509::Store.new @cert_store_crl_items = [] @client_cert = @client_key = @client_key_pass = @client_ca = nil @@ -309,6 +313,17 @@ def add_trust_ca(trust_ca_file_or_hashed_dir) end alias set_trust_ca add_trust_ca + # Selects a CA certificate file. Replaces the current certificate store + # with a new store loaded from file and resets existing sessions. If loading + # fails, the current store and selected file are unchanged. + def cacert_file=(file) + store = X509::Store.new + store._httpclient_cert_store_items.clear if defined?(JRUBY_VERSION) + add_trust_ca_to_store(store, file) + @cacert_file = file + self.cert_store = store + end + def add_trust_ca_to_store(cert_store, trust_ca_file_or_hashed_dir) if FileTest.directory?(trust_ca_file_or_hashed_dir) cert_store.add_path(trust_ca_file_or_hashed_dir) @@ -317,8 +332,9 @@ def add_trust_ca_to_store(cert_store, trust_ca_file_or_hashed_dir) end end - # Loads default trust anchors. - # Calling this method resets all existing sessions. + # Loads the selected cacert_file into the current certificate store. + # Existing certificates in the store are kept. Calling this method resets + # all existing sessions. def load_trust_ca load_cacerts(@cert_store) change_notify @@ -521,10 +537,8 @@ def change_notify nil end - # Use 2048 bit certs trust anchor def load_cacerts(cert_store) - file = File.join(File.dirname(__FILE__), 'cacert.pem') - add_trust_ca_to_store(cert_store, file) + add_trust_ca_to_store(cert_store, @cacert_file) unless @cacert_file.nil? end end diff --git a/test/test_ssl.rb b/test/test_ssl.rb index c837274..1f615a2 100644 --- a/test/test_ssl.rb +++ b/test/test_ssl.rb @@ -106,6 +106,8 @@ def test_blocked_peer_certificate_before_request def test_blocked_intermediate_with_default_paths escape_env do + # Nix's OpenSSL can prioritize this CA bundle path over SSL_CERT_FILE. + ENV.delete('NIX_SSL_CERT_FILE') ENV['SSL_CERT_FILE'] = path('ca-chain.pem') @client.ssl_config.set_default_paths @server.ssl_context.extra_chain_cert = [cert('subca.cert')] @@ -373,6 +375,8 @@ def test_set_default_paths @client.get(@url) end escape_env do + # Nix's OpenSSL can prioritize this CA bundle path over SSL_CERT_FILE. + ENV.delete('NIX_SSL_CERT_FILE') ENV['SSL_CERT_FILE'] = File.join(DIR, 'ca-chain.pem') @client.ssl_config.set_default_paths @client.get(@url) diff --git a/test/test_ssl_config.rb b/test/test_ssl_config.rb index 67b82d2..c013f5a 100644 --- a/test/test_ssl_config.rb +++ b/test/test_ssl_config.rb @@ -49,3 +49,88 @@ def test_blocked_cross_signed_keys_without_der_export end end end + +class TestCACertFile < Test::Unit::TestCase + def setup + @client = HTTPClient.new + @config = @client.ssl_config + @ca_file = File.join(__dir__, 'ca.cert') + end + + def teardown + @client.reset_all + end + + if defined?(JRUBY_VERSION) + def test_load_trust_ca_keeps_default_selection_when_no_file_is_selected + default_items = @config.cert_store_items.dup + @config.load_trust_ca + + assert_nil(@config.cacert_file) + assert_equal(default_items, @config.cert_store_items) + end + end + + def test_cacert_file_replaces_store + @config.add_trust_ca(File.join(__dir__, 'ca-chain.pem')) + original_store = @config.cert_store + @config.cacert_file = @ca_file + + assert_not_same(original_store, @config.cert_store) + assert_equal(@ca_file, @config.cacert_file) + assert_equal([@ca_file], @config.cert_store_items) if defined?(JRUBY_VERSION) + end + + def test_load_trust_ca_uses_selected_file + @config.cacert_file = @ca_file + @config.clear_cert_store + @config.load_trust_ca + + certificate = OpenSSL::X509::Certificate.new(File.read(@ca_file)) + @config.cert_store.time = certificate.not_before + 1 + assert(@config.cert_store.verify(certificate), @config.cert_store.error_string) + end +end + +# JRuby uses its JVM trust manager instead of SSLConfig#set_context. +unless defined?(JRUBY_VERSION) + class TestOpenSSLTrustStore < Test::Unit::TestCase + def setup + @client = HTTPClient.new + @config = @client.ssl_config + @loaded_files = [] + loaded_files = @loaded_files + @config.define_singleton_method(:add_trust_ca_to_store) do |_store, file| + loaded_files << file + end + end + + def teardown + @client.reset_all if @client + end + + def test_default_cert_file_is_loaded_once + configure_context + configure_context + assert_equal([OpenSSL::X509::DEFAULT_CERT_FILE], @loaded_files) + end + + def test_bundled_ca_can_be_selected_with_cacert_file + @config.cacert_file = bundled_ca_file + configure_context + assert_equal([bundled_ca_file], @loaded_files) + end + + private + + def configure_context + context = OpenSSL::SSL::SSLContext.new + @config.set_context(context) + assert_same(@config.cert_store, context.cert_store) + end + + def bundled_ca_file + HTTPClient::SSLConfig::BUNDLED_CACERT_FILE + end + end +end