Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
56 changes: 35 additions & 21 deletions lib/httpclient/ssl_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions test/test_ssl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')]
Expand Down Expand Up @@ -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)
Expand Down
85 changes: 85 additions & 0 deletions test/test_ssl_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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