From 9e933b334e041766f15e3aae3a0efac27861d332 Mon Sep 17 00:00:00 2001 From: "Yamashita, Yuu" Date: Fri, 23 Oct 2026 18:04:47 +0900 Subject: [PATCH 1/6] Reject compromised eMudhra intermediate CA keys --- CHANGELOG.md | 2 + lib/httpclient/jruby_ssl_socket.rb | 11 ++++- lib/httpclient/ssl_config.rb | 55 ++++++++++++++++++++++- lib/httpclient/ssl_socket.rb | 1 + test/fixtures/emudhra-dv-g3b.pem | 47 ++++++++++++++++++++ test/test_ssl.rb | 70 ++++++++++++++++++++++++++++++ 6 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/emudhra-dv-g3b.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index a24a4c05..17d95ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Changes +* Reject TLS chains containing compromised eMudhra intermediate CA keys, including cross-signed variants ([#480](https://github.com/nahi/httpclient/issues/480)). + ## Change Log ### Changes in 2.9.0 diff --git a/lib/httpclient/jruby_ssl_socket.rb b/lib/httpclient/jruby_ssl_socket.rb index 1a0db8ea..75b4021e 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 @@ -213,7 +217,9 @@ def initialize(verify_callback) end def checkServerTrusted(chain, authType) - @verify_callback.call(true, chain) + unless @verify_callback.call(true, chain) + raise OpenSSL::SSL::SSLError.new('verifycallback failed') + end end def checkClientTrusted(chain, authType); end @@ -527,7 +533,7 @@ def create_ssl_context(config) end trust_store = nil - verify_callback = config.verify_callback || config.method(:default_verify_callback) + verify_callback = config.method(:certificate_verify_callback) if !config.verify? tmf = VerifyNoneTrustManagerFactory.new(verify_callback) else @@ -570,6 +576,7 @@ def peer_cert def ssl_connect(hostname) @ssl_socket.startHandshake ssl_session = @ssl_socket.getSession + @config.check_peer_certificates(ssl_session.getPeerCertificates) @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..7799484e 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -89,6 +89,26 @@ def attr_config(symbol) CIPHERS_DEFAULT = "ALL:!aNULL:!eNULL:!SSLv2" # OpenSSL >1.0.0 default + # SHA-256 hashes of DER-encoded SubjectPublicKeyInfo for eight distrusted + # eMudhra intermediate CA keys, covering cross-signed variants. + # 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 = %w[ + 43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b + 49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f + 5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 + 63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 + 92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 + e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 + ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 + 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). @@ -294,7 +314,7 @@ def set_context(ctx) # :nodoc: ctx.cert_store = @cert_store ctx.verify_mode = @verify_mode ctx.verify_depth = @verify_depth if @verify_depth - ctx.verify_callback = @verify_callback || method(:default_verify_callback) + ctx.verify_callback = method(:certificate_verify_callback) # SSL config if @client_cert ctx.cert = @client_cert.is_a?(X509::Certificate) ? @client_cert : @@ -311,6 +331,39 @@ def set_context(ctx) # :nodoc: ctx.ssl_version = @ssl_version unless @ssl_version == :auto end + # Reject compromised keys regardless of the configured trust store or callback. + 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 + else + public_key.to_der + end + if self.class.blocked_public_key?(public_key_der) + raise SSL::SSLError, 'certificate chain contains a compromised eMudhra public key' + end + end + end + + def certificate_verify_callback(is_ok, ctx) # :nodoc: + begin + # Check the full chain so a later user callback cannot override rejection. + check_peer_certificates(ctx.chain) + rescue SSL::SSLError + return false + end + callback = @verify_callback || method(:default_verify_callback) + callback.call(is_ok, ctx) + 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: diff --git a/lib/httpclient/ssl_socket.rb b/lib/httpclient/ssl_socket.rb index 14801428..c1547cf9 100644 --- a/lib/httpclient/ssl_socket.rb +++ b/lib/httpclient/ssl_socket.rb @@ -101,6 +101,7 @@ def ssl_connect(hostname = nil) @ssl_socket.hostname = hostname end @ssl_socket.connect + @config.check_peer_certificates(@ssl_socket.peer_cert_chain) 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..6b245401 100644 --- a/test/test_ssl.rb +++ b/test/test_ssl.rb @@ -61,6 +61,62 @@ 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' } + @server.ssl_context.extra_chain_cert = [cert('subca.cert')] + + with_blocked_public_key(cert('subca.cert')) do + [nil, proc { true }].each do |callback| + cfg.verify_callback = callback + 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|compromised eMudhra/, error.message) + end + end + assert(requests.empty?, 'HTTP request reached a server with a blocked intermediate') + 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_verify_none + @client.ssl_config.add_trust_ca(path('ca-chain.pem')) + @client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE + @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_content(@url) } + end + end + unless defined?(HTTPClient::JRubySSLSocket) # JRubySSLSocket does not support sync mode. def test_sync @@ -392,6 +448,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 From 2829e1cec65ef1da5a48d114ea32a12c6a4b0b66 Mon Sep 17 00:00:00 2001 From: "Yamashita, Yuu" Date: Fri, 23 Oct 2026 18:08:35 +0900 Subject: [PATCH 2/6] Annotate distrusted eMudhra public keys --- lib/httpclient/ssl_config.rb | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/httpclient/ssl_config.rb b/lib/httpclient/ssl_config.rb index 7799484e..7f184e1b 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -91,18 +91,29 @@ def attr_config(symbol) # SHA-256 hashes of DER-encoded SubjectPublicKeyInfo for eight distrusted # eMudhra intermediate CA keys, 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 = %w[ - 43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b - 49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f - 5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 - 63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 - 92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 - e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 - ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 - ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 + 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) From 4520e6312e8c4575b00fb492430a6797a276d69f Mon Sep 17 00:00:00 2001 From: "Yamashita, Yuu" Date: Sat, 24 Oct 2026 09:12:46 +0900 Subject: [PATCH 3/6] Respect TLS verification overrides and document blocked public keys --- CHANGELOG.md | 4 +- README.md | 29 +++++++++++ lib/httpclient/jruby_ssl_socket.rb | 6 ++- lib/httpclient/ssl_config.rb | 59 +++++++++++++++------- lib/httpclient/ssl_socket.rb | 4 +- test/test_ssl.rb | 79 +++++++++++++++++++++++++----- 6 files changed, 148 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af746048..1c44a50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ * Refresh the bundled Mozilla CA certificates to the October 20, 2026 snapshot, building on [#444](https://github.com/nahi/httpclient/pull/444). -* Reject TLS chains containing compromised eMudhra intermediate CA keys, including cross-signed variants ([#480](https://github.com/nahi/httpclient/issues/480)). +* 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 diff --git a/README.md b/README.md index 02962645..1efe77e4 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,35 @@ 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, and its return value controls certificate verification. The caller is +responsible for the custom callback's validation policy. Hostname verification +is unchanged. + +This mitigation does not guarantee rejection of certificates containing blocked +public keys when those certificates are found only in a locally configured +trust store. + +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 75b4021e..8009247f 100644 --- a/lib/httpclient/jruby_ssl_socket.rb +++ b/lib/httpclient/jruby_ssl_socket.rb @@ -533,7 +533,7 @@ def create_ssl_context(config) end trust_store = nil - verify_callback = config.method(:certificate_verify_callback) + verify_callback = config.verify_callback || config.method(:default_verify_callback) if !config.verify? tmf = VerifyNoneTrustManagerFactory.new(verify_callback) else @@ -576,7 +576,9 @@ def peer_cert def ssl_connect(hostname) @ssl_socket.startHandshake ssl_session = @ssl_socket.getSession - @config.check_peer_certificates(ssl_session.getPeerCertificates) + 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 7f184e1b..9b9f994a 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -34,6 +34,30 @@ class HTTPClient # # 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, and its return value controls certificate verification. + # The caller is responsible for the custom callback's validation policy. + # Hostname verification is unchanged. + # + # This mitigation does not guarantee rejection of certificates containing + # blocked public keys when those certificates are found only in a locally + # configured trust store. + # See https://github.com/nahi/httpclient/issues/480 for background. class SSLConfig include HTTPClient::Util if SSLEnabled @@ -89,8 +113,10 @@ def attr_config(symbol) CIPHERS_DEFAULT = "ALL:!aNULL:!eNULL:!SSLv2" # OpenSSL >1.0.0 default - # SHA-256 hashes of DER-encoded SubjectPublicKeyInfo for eight distrusted - # eMudhra intermediate CA keys, covering cross-signed variants. + # 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 @@ -140,6 +166,7 @@ def self.blocked_public_key?(public_key_der) # 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. @@ -150,6 +177,9 @@ def self.blocked_public_key?(public_key_der) # 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 @@ -325,7 +355,7 @@ def set_context(ctx) # :nodoc: ctx.cert_store = @cert_store ctx.verify_mode = @verify_mode ctx.verify_depth = @verify_depth if @verify_depth - ctx.verify_callback = method(:certificate_verify_callback) + ctx.verify_callback = @verify_callback || method(:default_verify_callback) # SSL config if @client_cert ctx.cert = @client_cert.is_a?(X509::Certificate) ? @client_cert : @@ -342,7 +372,8 @@ def set_context(ctx) # :nodoc: ctx.ssl_version = @ssl_version unless @ssl_version == :auto end - # Reject compromised keys regardless of the configured trust store or callback. + # 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| @@ -359,22 +390,11 @@ def check_peer_certificates(certificates) # :nodoc: public_key.to_der end if self.class.blocked_public_key?(public_key_der) - raise SSL::SSLError, 'certificate chain contains a compromised eMudhra public key' + raise SSL::SSLError, 'certificate chain contains a blocked eMudhra public key' end end end - def certificate_verify_callback(is_ok, ctx) # :nodoc: - begin - # Check the full chain so a later user callback cannot override rejection. - check_peer_certificates(ctx.chain) - rescue SSL::SSLError - return false - end - callback = @verify_callback || method(:default_verify_callback) - callback.call(is_ok, ctx) - 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: @@ -404,8 +424,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 c1547cf9..c6a919a8 100644 --- a/lib/httpclient/ssl_socket.rb +++ b/lib/httpclient/ssl_socket.rb @@ -101,7 +101,9 @@ def ssl_connect(hostname = nil) @ssl_socket.hostname = hostname end @ssl_socket.connect - @config.check_peer_certificates(@ssl_socket.peer_cert_chain) + 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/test_ssl.rb b/test/test_ssl.rb index 6b245401..f3d8b60e 100644 --- a/test/test_ssl.rb +++ b/test/test_ssl.rb @@ -79,18 +79,29 @@ def test_blocked_peer_certificate_before_request cfg.add_trust_ca(path('ca-chain.pem')) requests = Queue.new @server.mount_proc('/blocked') { |req, res| requests << req; res.body = 'unexpected' } - @server.ssl_context.extra_chain_cert = [cert('subca.cert')] + 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)) - with_blocked_public_key(cert('subca.cert')) do - [nil, proc { true }].each do |callback| - cfg.verify_callback = callback - 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|compromised eMudhra/, error.message) + @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 with a blocked intermediate') + 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 @@ -106,13 +117,52 @@ def test_blocked_intermediate_with_default_paths end end - def test_blocked_intermediate_with_verify_none - @client.ssl_config.add_trust_ca(path('ca-chain.pem')) - @client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE + 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 + + 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 @@ -379,6 +429,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") From 92dda8650b05d80488486fad350073d0b878a9ab Mon Sep 17 00:00:00 2001 From: "Yamashita, Yuu" Date: Sat, 24 Oct 2026 14:04:20 +0900 Subject: [PATCH 4/6] Clarify JRuby certificate validation limits --- README.md | 8 +++++--- lib/httpclient/ssl_config.rb | 9 ++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1efe77e4..b3611f69 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,11 @@ context, and its return value controls certificate verification. The caller is responsible for the custom callback's validation policy. Hostname verification is unchanged. -This mitigation does not guarantee rejection of certificates containing blocked -public keys when those certificates are found only in a locally configured -trust store. +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 diff --git a/lib/httpclient/ssl_config.rb b/lib/httpclient/ssl_config.rb index 9b9f994a..cb1ea3dc 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -32,6 +32,9 @@ 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. # @@ -54,9 +57,9 @@ class HTTPClient # The caller is responsible for the custom callback's validation policy. # Hostname verification is unchanged. # - # This mitigation does not guarantee rejection of certificates containing - # blocked public keys when those certificates are found only in a locally - # configured trust store. + # 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 From ebe30c7a4781e8aa1042fe9484c238f74ce7b154 Mon Sep 17 00:00:00 2001 From: "Yamashita, Yuu" Date: Sat, 24 Oct 2026 14:40:37 +0900 Subject: [PATCH 5/6] Preserve JRuby VERIFY_NONE callback behavior --- README.md | 6 +++--- lib/httpclient/jruby_ssl_socket.rb | 4 +--- lib/httpclient/ssl_config.rb | 3 ++- test/test_ssl.rb | 17 +++++++++++++++++ 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b3611f69..80e695b6 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,9 @@ 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, and its return value controls certificate verification. The caller is -responsible for the custom callback's validation policy. Hostname verification -is unchanged. +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 diff --git a/lib/httpclient/jruby_ssl_socket.rb b/lib/httpclient/jruby_ssl_socket.rb index 8009247f..4a30db66 100644 --- a/lib/httpclient/jruby_ssl_socket.rb +++ b/lib/httpclient/jruby_ssl_socket.rb @@ -217,9 +217,7 @@ def initialize(verify_callback) end def checkServerTrusted(chain, authType) - unless @verify_callback.call(true, chain) - raise OpenSSL::SSL::SSLError.new('verifycallback failed') - end + @verify_callback.call(true, chain) end def checkClientTrusted(chain, authType); end diff --git a/lib/httpclient/ssl_config.rb b/lib/httpclient/ssl_config.rb index cb1ea3dc..111c4dd7 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -53,7 +53,8 @@ class HTTPClient # 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, and its return value controls certificate verification. + # 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. # diff --git a/test/test_ssl.rb b/test/test_ssl.rb index f3d8b60e..35cd23d2 100644 --- a/test/test_ssl.rb +++ b/test/test_ssl.rb @@ -148,6 +148,23 @@ def test_blocked_intermediate_with_verification_overrides 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 From 3f50ba151e89863ef649d8fe3c1dca38de44f74e Mon Sep 17 00:00:00 2001 From: "Yamashita, Yuu" Date: Mon, 26 Oct 2026 16:50:49 +0900 Subject: [PATCH 6/6] Support public keys without DER export on older ruby-openssl --- lib/httpclient/ssl_config.rb | 8 +++++- test/test_ssl_config.rb | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 test/test_ssl_config.rb diff --git a/lib/httpclient/ssl_config.rb b/lib/httpclient/ssl_config.rb index 111c4dd7..29944427 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -390,8 +390,14 @@ def check_peer_certificates(certificates) # :nodoc: String.from_java_bytes(public_key.getEncoded) elsif public_key.respond_to?(:public_to_der) public_key.public_to_der - else + 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' 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