Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions lib/httpclient/jruby_ssl_socket.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ def subject
@cert.getSubjectDN
end

def public_key
@cert.getPublicKey
end

def to_text
@cert.toString
end
Expand Down Expand Up @@ -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}")
Expand Down
101 changes: 100 additions & 1 deletion lib/httpclient/ssl_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")
Expand Down
3 changes: 3 additions & 0 deletions lib/httpclient/ssl_socket.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
47 changes: 47 additions & 0 deletions test/fixtures/emudhra-dv-g3b.pem
Original file line number Diff line number Diff line change
@@ -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-----
Loading
Loading