diff --git a/CHANGELOG.md b/CHANGELOG.md index 755d6b31..1c44a50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ * Refresh the bundled Mozilla CA certificates to the October 20, 2026 snapshot, building on [#444](https://github.com/nahi/httpclient/pull/444). +* With peer verification enabled and no custom verification callback, reject TLS connections when a server supplies certificates containing blocked eMudhra intermediate CA public keys, including cross-signed variants ([#480](https://github.com/nahi/httpclient/issues/480)). + Connections are also rejected when a server supplies an unused certificate containing a blocked public key. + `VERIFY_NONE`/`nil` disables this check; a custom verification callback replaces the default policy and is responsible for certificate validation. + ## Change Log ### Changes in 2.9.0 diff --git a/README.md b/README.md index 02962645..80e695b6 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,37 @@ 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 certificate restrictions + +Blocked public keys are identified by their SPKI SHA-256 hashes in +`HTTPClient::SSLConfig::BLOCKED_SPKI_SHA256`. The list covers eight eMudhra +intermediate CA public keys whose corresponding private keys were compromised. + +With peer verification enabled and no custom `verify_callback`, HTTPClient +rejects a connection before sending an HTTP request if any certificate supplied +by the server contains a blocked eMudhra public key. This includes cross-signed +certificates and extra certificates that are not used in the verification path, +even when another valid path exists. + +Setting `verify_mode` to `OpenSSL::SSL::VERIFY_NONE` or `nil` disables this check. +A custom `verify_callback` replaces the default verification policy, including +the blocked public key check. It receives the underlying verifier's result and +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). + +Remove unused certificates containing blocked public keys from the server's +certificate chain configuration. If a certificate containing a blocked public key +is required for validation, replace the affected server certificate and chain +with ones issued under unaffected CA keys. +See [#480](https://github.com/nahi/httpclient/issues/480) for background. + ## Bug report or Feature request Please file a ticket at the project web site. diff --git a/lib/httpclient/jruby_ssl_socket.rb b/lib/httpclient/jruby_ssl_socket.rb index 1a0db8ea..4a30db66 100644 --- a/lib/httpclient/jruby_ssl_socket.rb +++ b/lib/httpclient/jruby_ssl_socket.rb @@ -164,6 +164,10 @@ def subject @cert.getSubjectDN end + def public_key + @cert.getPublicKey + end + def to_text @cert.toString end @@ -570,6 +574,9 @@ def peer_cert def ssl_connect(hostname) @ssl_socket.startHandshake ssl_session = @ssl_socket.getSession + if @config.verify? && !@config.verify_callback + @config.check_peer_certificates(ssl_session.getPeerCertificates) + end @peer_cert = JavaCertificate.new(ssl_session.getPeerCertificates.first) if $DEBUG warn("Protocol version: #{ssl_session.getProtocol}") diff --git a/lib/httpclient/ssl_config.rb b/lib/httpclient/ssl_config.rb index 472db9d0..29944427 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -32,8 +32,36 @@ class HTTPClient # # HTTPClient.new { self.ssl_config.load_trust_ca }.get("https://...") # + # 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. + # + # == TLS certificate restrictions + # + # Blocked public keys are identified by their SPKI SHA-256 hashes in + # BLOCKED_SPKI_SHA256. The list covers eight eMudhra intermediate CA public + # keys whose corresponding private keys were compromised. + # + # With peer verification enabled and no custom verify_callback, HTTPClient + # rejects a connection before sending an HTTP request if any certificate + # supplied by the server contains a blocked eMudhra public key. This includes + # cross-signed certificates and extra certificates that are not used in the + # verification path, even when another valid path exists. + # + # Setting verify_mode to OpenSSL::SSL::VERIFY_NONE or nil disables this check. + # A custom verify_callback replaces the default verification policy, including + # the blocked public key check. The callback receives the underlying verifier's + # result and 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. + # + # On JRuby, this 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). These paths depend on the JVM's certificate validation. + # See https://github.com/nahi/httpclient/issues/480 for background. class SSLConfig include HTTPClient::Util if SSLEnabled @@ -89,6 +117,39 @@ def attr_config(symbol) CIPHERS_DEFAULT = "ALL:!aNULL:!eNULL:!SSLv2" # OpenSSL >1.0.0 default + # Public keys whose SPKI SHA-256 hashes are listed below are considered blocked. + # The list covers eight eMudhra intermediate CA public keys whose corresponding + # private keys were compromised. Hashes use DER-encoded SubjectPublicKeyInfo, + # covering cross-signed variants. + # Includes six TLS keys and two code-signing keys. + # CA names are from CCADB's public intermediate certificate inventory: + # https://ccadb.my.salesforce-sites.com/mozilla/PublicAllIntermediateCertsWithPEMCSV + # Matches distrustedSPKIHashes in Go's crypto/x509 at: + # https://github.com/golang/go/blob/7f86f957633ee31a1c17a1eb8452ca7a3d9bc141/src/crypto/x509/verify.go + # Related Go mitigation: https://go.dev/issue/82345 (CVE-2026-111943). + BLOCKED_SPKI_SHA256 = [ + # EM OV TLS CA - G3B + '43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b', + # EM DV TLS CA - G3C + '49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f', + # EM EV TLS CA - G3C + '5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0', + # EM OV TLS CA - G3C + '63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8', + # EM OV CS CA - G3C + '92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30', + # EM EV TLS CA - G3B + 'e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41', + # EM DV TLS CA - G3B + 'ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105', + # EM OV CS CA - G3B + 'ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6', + ].map(&:freeze).freeze + + def self.blocked_public_key?(public_key_der) + BLOCKED_SPKI_SHA256.include?(OpenSSL::Digest::SHA256.hexdigest(public_key_der)) + end + # Which TLS protocol version (also called method) will be used. Defaults # to :auto which means that OpenSSL decides (In my tests this resulted # with always the highest available protocol being used). @@ -109,6 +170,7 @@ def attr_config(symbol) # A number which represents OpenSSL's verify mode. Default value is # OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT. + # VERIFY_NONE or nil disables the default blocked public key check. attr_config :verify_mode # A number of verify depth. Certification path which length is longer than # this depth is not allowed. @@ -119,6 +181,9 @@ def attr_config(symbol) # OpenSSL's verification. handler.call is invoked with 2 arguments, # ok and ctx; ok is a result of general OpenSSL's verification. ctx is a # OpenSSL::X509::StoreContext. + # Setting a handler replaces the default verification policy, including its + # blocked public key check. The caller is responsible for the + # handler's validation policy. Hostname verification is unchanged. attr_config :verify_callback # SSL timeout in sec. nil by default. attr_config :timeout @@ -311,6 +376,35 @@ def set_context(ctx) # :nodoc: ctx.ssl_version = @ssl_version unless @ssl_version == :auto end + # Reject blocked public keys in the given certificate collection. + # Peer certificates are checked even when unused in the verification path. + def check_peer_certificates(certificates) # :nodoc: + certificates = certificates.to_a if certificates.respond_to?(:to_a) + certificates.each do |certificate| + public_key = if certificate.respond_to?(:public_key) + certificate.public_key + else + certificate.getPublicKey + end + public_key_der = if public_key.respond_to?(:getEncoded) + String.from_java_bytes(public_key.getEncoded) + elsif public_key.respond_to?(:public_to_der) + public_key.public_to_der + elsif public_key.respond_to?(:to_der) + public_key.to_der + else + # Older ruby-openssl cannot export generic PKey objects. + fields = OpenSSL::ASN1.decode(certificate.to_der).value[0].value + # The optional version field shifts SubjectPublicKeyInfo by one. + spki_index = fields[0].tag_class == :CONTEXT_SPECIFIC ? 6 : 5 + fields[spki_index].to_der + end + if self.class.blocked_public_key?(public_key_der) + raise SSL::SSLError, 'certificate chain contains a blocked eMudhra public key' + end + end + end + # post connection check proc for ruby < 1.8.5. # this definition must match with the one in ext/openssl/lib/openssl/ssl.rb def post_connection_check(peer_cert, hostname) # :nodoc: @@ -340,8 +434,13 @@ def post_connection_check(peer_cert, hostname) # :nodoc: raise SSL::SSLError, "hostname was not match with the server certificate" end - # Default callback for verification: only dumps error. + # Default callback: reject blocked public keys and report verification errors. def default_verify_callback(is_ok, ctx) + begin + check_peer_certificates(ctx.chain) if verify? + rescue SSL::SSLError + return false + end if $DEBUG if is_ok warn("ok: #{ctx.current_cert.subject.to_s.dump}") diff --git a/lib/httpclient/ssl_socket.rb b/lib/httpclient/ssl_socket.rb index 14801428..c6a919a8 100644 --- a/lib/httpclient/ssl_socket.rb +++ b/lib/httpclient/ssl_socket.rb @@ -101,6 +101,9 @@ def ssl_connect(hostname = nil) @ssl_socket.hostname = hostname end @ssl_socket.connect + if @config.verify? && !@config.verify_callback + @config.check_peer_certificates(@ssl_socket.peer_cert_chain) + end if $DEBUG if @ssl_socket.respond_to?(:ssl_version) warn("Protocol version: #{@ssl_socket.ssl_version}") diff --git a/test/fixtures/emudhra-dv-g3b.pem b/test/fixtures/emudhra-dv-g3b.pem new file mode 100644 index 00000000..fdc4c327 --- /dev/null +++ b/test/fixtures/emudhra-dv-g3b.pem @@ -0,0 +1,47 @@ +# EM DV TLS CA - G3B, signed by emSign Root TLS CA - G3 and AAA Certificate Services. +# Public certificates from https://emudhra.we-have-a-problem.com/chains.zip +# SPKI SHA-256: ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 +-----BEGIN CERTIFICATE----- +MIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw +CQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl +ZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1 +NDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11 +ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J +hZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p +1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L +WUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG +CCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow +KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB +BAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY +aHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw +b3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f +BDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j +cmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E +AwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO +Ibh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8 +gt2TlfJbHii+KNvK +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw +ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV +BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y +ODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW +MRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO +L4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2 +PM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd +BgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG +A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC +MDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h +AQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB +QUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF +BQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB +AQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE +wqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM +QkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ +7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl +bN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V +GUyRCEIDhF5TBUuKF4E3kf1h +-----END CERTIFICATE----- diff --git a/test/test_ssl.rb b/test/test_ssl.rb index 1ec52c04..35cd23d2 100644 --- a/test/test_ssl.rb +++ b/test/test_ssl.rb @@ -61,6 +61,129 @@ def test_options assert_instance_of(OpenSSL::X509::Store, cfg.cert_store) end + def test_emudhra_cross_signed_public_key + certificates = read_path('fixtures/emudhra-dv-g3b.pem').scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m).map { |pem| + OpenSSL::X509::Certificate.new(pem) + } + assert_equal(2, certificates.size) + assert_not_equal(certificates[0].issuer, certificates[1].issuer) + assert_equal(8, HTTPClient::SSLConfig::BLOCKED_SPKI_SHA256.size) + certificates.each do |certificate| + assert(HTTPClient::SSLConfig.blocked_public_key?(certificate.public_key.to_der)) + end + assert_equal(false, HTTPClient::SSLConfig.blocked_public_key?(cert('ca.cert').public_key.to_der)) + end + + def test_blocked_peer_certificate_before_request + cfg = @client.ssl_config + cfg.add_trust_ca(path('ca-chain.pem')) + requests = Queue.new + @server.mount_proc('/blocked') { |req, res| requests << req; res.body = 'unexpected' } + intermediate = cert('subca.cert') + # This unrelated self-signed certificate cannot be part of the valid path. + unused = cert('fixtures/verify.foo.cert') + @server.ssl_context.extra_chain_cert = [intermediate, unused] + assert_equal('hello', @client.get_content(@url)) + + @client.reset_all + with_blocked_public_key(intermediate) do + error = assert_raise(OpenSSL::SSL::SSLError) do + @client.post("https://localhost:#{serverport}/blocked", 'secret', 'Authorization' => 'secret') + end + assert_match(/certificate verify failed|verifycallback failed|revoked|blocked eMudhra/, error.message) + end + assert(requests.empty?, 'HTTP request reached a server supplying an intermediate containing a blocked public key') + + @client.reset_all + with_blocked_public_key(unused) do + error = assert_raise(OpenSSL::SSL::SSLError) do + @client.post("https://localhost:#{serverport}/blocked", 'secret', 'Authorization' => 'secret') + end + assert_match(/certificate verify failed|verifycallback failed|revoked|blocked eMudhra/, error.message) + end + assert(requests.empty?, 'HTTP request reached a server supplying an unused certificate containing a blocked public key') + end + + def test_blocked_intermediate_with_default_paths + escape_env do + ENV['SSL_CERT_FILE'] = path('ca-chain.pem') + @client.ssl_config.set_default_paths + @server.ssl_context.extra_chain_cert = [cert('subca.cert')] + assert_equal('hello', @client.get_content(@url)) + @client.reset_all + with_blocked_public_key(cert('subca.cert')) do + assert_raise(OpenSSL::SSL::SSLError) { @client.get(@url) } + end + end + end + + def test_blocked_intermediate_with_verification_overrides + cfg = @client.ssl_config + cfg.add_trust_ca(path('ca-chain.pem')) + @server.ssl_context.extra_chain_cert = [cert('subca.cert')] + assert_equal('hello', @client.get_content(@url)) + @client.reset_all + with_blocked_public_key(cert('subca.cert')) do + cfg.verify_mode = OpenSSL::SSL::VERIFY_NONE + assert_equal('hello', @client.get_content(@url)) + + cfg.verify_mode = nil + assert_equal('hello', @client.get_content(@url)) + + cfg.verify_mode = OpenSSL::SSL::VERIFY_PEER + callback_results = [] + cfg.verify_callback = proc { |ok, ctx| callback_results << ok; true } + assert_equal('hello', @client.get_content(@url)) + assert(!callback_results.empty?, 'custom verification callback was not invoked') + assert(callback_results.all?, 'custom callback did not receive the original verification result') + + cfg.verify_callback = proc { |ok, ctx| false } + assert_raise(OpenSSL::SSL::SSLError) { @client.get_content(@url) } + + cfg.verify_callback = cfg.method(:default_verify_callback) + assert_raise(OpenSSL::SSL::SSLError) { @client.get_content(@url) } + + cfg.verify_callback = nil + assert_raise(OpenSSL::SSL::SSLError) { @client.get_content(@url) } + end + end + + data('VERIFY_NONE with nil' => [OpenSSL::SSL::VERIFY_NONE, nil], + 'VERIFY_NONE with false' => [OpenSSL::SSL::VERIFY_NONE, false], + 'nil with nil' => [nil, nil], + 'nil with false' => [nil, false]) + def test_verification_disabled_ignores_callback_result(settings) + verify_mode, callback_result = settings + cfg = @client.ssl_config + cfg.verify_mode = verify_mode + callback_certificates = [] + cfg.verify_callback = proc { |ok, ctx| + callback_certificates << ctx.current_cert + callback_result + } + assert_equal('hello', @client.get_content(@url)) + assert(!callback_certificates.empty?, 'custom verification callback was not invoked') + end + + def test_custom_verify_callback_with_untrusted_blocked_intermediate + cfg = @client.ssl_config + cfg.clear_cert_store + @server.ssl_context.extra_chain_cert = [cert('subca.cert')] + callback_results = [] + cfg.verify_callback = proc { |ok, ctx| + callback_results << [ok, ctx.error] + true + } + with_blocked_public_key(cert('subca.cert')) do + assert_equal('hello', @client.get_content(@url)) + assert(callback_results.any? { |ok, error| !ok && error && error != 0 }, + 'custom callback did not receive the underlying verification error') + + cfg.verify_callback = proc { |ok, ctx| ok } + assert_raise(OpenSSL::SSL::SSLError) { @client.get_content(@url) } + end + end + unless defined?(HTTPClient::JRubySSLSocket) # JRubySSLSocket does not support sync mode. def test_sync @@ -323,6 +446,11 @@ def test_post_connection_check assert_raises(OpenSSL::SSL::SSLError) do @client.get("https://localhost:#{serverport}/hello") end + @client.ssl_config.verify_callback = proc { |ok, ctx| true } + assert_raises(OpenSSL::SSL::SSLError) do + @client.get("https://localhost:#{serverport}/hello") + end + @client.ssl_config.verify_callback = nil @client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE assert_nothing_raised do @client.get("https://localhost:#{serverport}/hello") @@ -392,6 +520,20 @@ def test_timeout private + def with_blocked_public_key(certificate) + config = HTTPClient::SSLConfig + original = config::BLOCKED_SPKI_SHA256 + digest = OpenSSL::Digest::SHA256.hexdigest(certificate.public_key.to_der) + config.send(:remove_const, :BLOCKED_SPKI_SHA256) + config.const_set(:BLOCKED_SPKI_SHA256, original + [digest]) + yield + ensure + if original + config.send(:remove_const, :BLOCKED_SPKI_SHA256) + config.const_set(:BLOCKED_SPKI_SHA256, original) + end + end + def cert(filename) OpenSSL::X509::Certificate.new(File.read(File.join(DIR, filename))) end diff --git a/test/test_ssl_config.rb b/test/test_ssl_config.rb new file mode 100644 index 00000000..67b82d2c --- /dev/null +++ b/test/test_ssl_config.rb @@ -0,0 +1,51 @@ +require 'test/unit' +require 'httpclient' + +class TestSSLConfig < Test::Unit::TestCase + # Older ruby-openssl returns generic PKey objects without DER export methods. + CertificateWithoutKeyExport = Struct.new(:to_der) do + def public_key + Object.new + end + end + + data('X.509 v1' => false, 'X.509 v3' => true) + def test_public_key_without_der_export(explicit_version) + certificate = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'ca.cert'))) + public_key_der = certificate.public_key.to_der + encoded = OpenSSL::ASN1.decode(certificate.to_der) + unless explicit_version + # Only the DER layout matters here; the checker does not verify signatures. + fields = encoded.value[0].value + fields.shift + fields.reject! { |field| field.tag_class == :CONTEXT_SPECIFIC && field.tag == 3 } + end + peer = CertificateWithoutKeyExport.new(encoded.to_der) + config = HTTPClient::SSLConfig.new(nil) + assert_nothing_raised { config.check_peer_certificates([peer]) } + + original = HTTPClient::SSLConfig::BLOCKED_SPKI_SHA256 + HTTPClient::SSLConfig.send(:remove_const, :BLOCKED_SPKI_SHA256) + HTTPClient::SSLConfig.const_set(:BLOCKED_SPKI_SHA256, + original + [OpenSSL::Digest::SHA256.hexdigest(public_key_der)]) + assert_raise(OpenSSL::SSL::SSLError) { config.check_peer_certificates([peer]) } + ensure + if original + HTTPClient::SSLConfig.send(:remove_const, :BLOCKED_SPKI_SHA256) + HTTPClient::SSLConfig.const_set(:BLOCKED_SPKI_SHA256, original) + end + end + + def test_blocked_cross_signed_keys_without_der_export + pem = File.read(File.join(__dir__, 'fixtures/emudhra-dv-g3b.pem')) + certificates = pem.scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m) + assert_equal(2, certificates.size) + certificates.each do |encoded| + certificate = OpenSSL::X509::Certificate.new(encoded) + peer = CertificateWithoutKeyExport.new(certificate.to_der) + assert_raise(OpenSSL::SSL::SSLError) do + HTTPClient::SSLConfig.new(nil).check_peer_certificates([peer]) + end + end + end +end