Thank you very much for taking the time to research this and explain it to me!
Original Message:
Sent: 07/16/26 02:27 PM
From: ANGUO LI
Subject: Java 17.0.19.10: TLS Alert Changed from bad_certificate to handshake_failure
Hi Sergey, I deeply looked into the code from Oracle's open source, this is exactly the data we needed, and your testing pinned it down. Swapping in the 17.0.18 java.security and seeing no change conclusively rules out a configuration/algorithm-list difference, and your certificate is SHA-256 (sha256WithRSAEncryption), so it was never an algorithm-disablement issue. This is an intentional code behavior change in JSSE - not a bug, and not something in java.security.
What changed. The alert sent when a client presents an empty certificate chain under required mutual authentication was changed between 17.0.18 and 17.0.19:
- 17.0.18:
Fatal (BAD_CERTIFICATE): Empty client certificate chain - 17.0.19:
Fatal (HANDSHAKE_FAILURE): Empty client certificate chain
Everything else is identical - same trigger (empty client Certificate message), same code path, same exception text. Only the alert code (42 → 40) and its label changed.
Where, in the JSSE source. This is in sun.security.ssl.CertificateMessage, in T12CertificateConsumer.onCertificate - the exact class and line in your stack trace, CertificateMessage.java:390:
if (encodedCerts == null || encodedCerts.isEmpty()) {
...
if (shc.sslConfig.clientAuthType != ClientAuthType.CLIENT_AUTH_REQUESTED) {
throw shc.conContext.fatal(Alert.BAD_CERTIFICATE, // 17.0.18
"Empty client certificate chain"); // → Alert.HANDSHAKE_FAILURE in 17.0.19
} else {
return; // optional client auth → continue
}
}
The change is a single alert token on that line: Alert.BAD_CERTIFICATE → Alert.HANDSHAKE_FAILURE.
Why. This aligns JSSE with RFC 5246 §7.4.6, which states that if the client sends no certificate and the server requires client authentication, the server "[responds] with a fatal handshake_failure alert." The bad_certificate alert is defined for a certificate that is corrupt or has an invalid signature - not for an absent certificate - so handshake_failure is the correct alert here. The condition in the code maps directly to the RFC: CLIENT_AUTH_REQUIRED → fatal alert; optional (REQUESTED) → continue without client auth.
No security regression. In both versions the empty client certificate is rejected with a fatal alert and the handshake is aborted. Mutual TLS continues to work correctly and still refuses the connection - only the classification of the alert changed. So this behavior change is expected and should be adopted.
------------------------------
ANGUO LI
------------------------------
Original Message:
Sent: 07/10/26 05:15 AM
From: Sergey Korzhevsky
Subject: Java 17.0.19.10: TLS Alert Changed from bad_certificate to handshake_failure
Hello Anguo,
Thank you for the response, i gathered details, as you suggested.
- We are trying to connect with only CA cert, it has "Signature Algorithm: sha256WithRSAEncryption"
- Diff is the only in "jdk.security.caDistrustPolicies" - here "CHUNGHWA_TLS" is added. Also, i started Java 17.0.19 with the security from 17.0.18 and this didn't help
- Debug shows the following differences:
17.0.18:
javax.net.ssl|DEBUG|62|https-jsse-nio-0.0.0.0-9091-exec-2|2026-07-10 04:25:46.039 EDT|SSLEngineOutputRecord.java:530|WRITE: TLSv1.2 handshake, length = 1384
javax.net.ssl|DEBUG|52|https-jsse-nio-0.0.0.0-9091-exec-1|2026-07-10 04:25:46.041 EDT|SSLEngineInputRecord.java:213|READ: TLSv1.2 handshake, length = 7
javax.net.ssl|DEBUG|52|https-jsse-nio-0.0.0.0-9091-exec-1|2026-07-10 04:25:46.041 EDT|CertificateMessage.java:372|Consuming client Certificate handshake message (
"Certificates": <empty list>
)
javax.net.ssl|ERROR|52|https-jsse-nio-0.0.0.0-9091-exec-1|2026-07-10 04:25:46.043 EDT|TransportContext.java:375|Fatal (BAD_CERTIFICATE): Empty client certificate chain (
"throwable" : {
javax.net.ssl.SSLHandshakeException: Empty client certificate chain
at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:131)
at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:117)
at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:370)
at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:326)
at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:317)
at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.onCertificate(CertificateMessage.java:390)
at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.consume(CertificateMessage.java:375)
at java.base/sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:396)
at java.base/sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:481)
at java.base/sun.security.ssl.SSLEngineImpl$DelegatedTask$DelegatedAction.run(SSLEngineImpl.java:1277)
at java.base/sun.security.ssl.SSLEngineImpl$DelegatedTask$DelegatedAction.run(SSLEngineImpl.java:1264)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:793)
at java.base/sun.security.ssl.SSLEngineImpl$DelegatedTask.run(SSLEngineImpl.java:1209)
at org.apache.tomcat.util.net.SecureNioChannel.tasks(SecureNioChannel.java:433)
at org.apache.tomcat.util.net.SecureNioChannel.handshakeUnwrap(SecureNioChannel.java:504)
at org.apache.tomcat.util.net.SecureNioChannel.handshake(SecureNioChannel.java:217)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1755)
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:946)
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:480)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:57)
at java.base/java.lang.Thread.run(Thread.java:854)}
17.0.19:
javax.net.ssl|DEBUG|52|https-jsse-nio-0.0.0.0-9091-exec-1|2026-07-10 04:09:29.159 EDT|SSLEngineOutputRecord.java:530|WRITE: TLSv1.2 handshake, length = 1384
javax.net.ssl|DEBUG|62|https-jsse-nio-0.0.0.0-9091-exec-2|2026-07-10 04:09:29.161 EDT|SSLEngineInputRecord.java:213|READ: TLSv1.2 handshake, length = 7
javax.net.ssl|DEBUG|62|https-jsse-nio-0.0.0.0-9091-exec-2|2026-07-10 04:09:29.161 EDT|CertificateMessage.java:372|Consuming client Certificate handshake message (
"Certificates": <empty list>
)
javax.net.ssl|ERROR|62|https-jsse-nio-0.0.0.0-9091-exec-2|2026-07-10 04:09:29.162 EDT|TransportContext.java:375|Fatal (HANDSHAKE_FAILURE): Empty client certificate chain (
"throwable" : {
javax.net.ssl.SSLHandshakeException: (handshake_failure) Empty client certificate chain
at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:131)
at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:117)
at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:370)
at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:326)
at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:317)
at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.onCertificate(CertificateMessage.java:390)
at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.consume(CertificateMessage.java:375)
at java.base/sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:396)
at java.base/sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:481)
at java.base/sun.security.ssl.SSLEngineImpl$DelegatedTask$DelegatedAction.run(SSLEngineImpl.java:1278)
at java.base/sun.security.ssl.SSLEngineImpl$DelegatedTask$DelegatedAction.run(SSLEngineImpl.java:1265)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:793)
at java.base/sun.security.ssl.SSLEngineImpl$DelegatedTask.run(SSLEngineImpl.java:1210)
at org.apache.tomcat.util.net.SecureNioChannel.tasks(SecureNioChannel.java:433)
at org.apache.tomcat.util.net.SecureNioChannel.handshakeUnwrap(SecureNioChannel.java:504)
at org.apache.tomcat.util.net.SecureNioChannel.handshake(SecureNioChannel.java:217)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1755)
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:946)
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:480)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:57)
at java.base/java.lang.Thread.run(Thread.java:854)}
Basically, exception message "(BAD_CERTIFICATE): Empty client certificate chain" changed to "(HANDSHAKE_FAILURE): Empty client certificate chain"
------------------------------
Sergey Korzhevsky
Senior Software Engineer in Test
Rocket Software
------------------------------
Original Message:
Sent: 07/09/26 10:51 AM
From: ANGUO LI
Subject: Java 17.0.19.10: TLS Alert Changed from bad_certificate to handshake_failure
This is very likely a behavior change, not a bug - Java CPU releases routinely tighten the disabled/restricted algorithm lists in java.security (jdk.tls.disabledAlgorithms, jdk.certpath.disabledAlgorithms).
In TLS 1.2 mutual auth, a bad client cert can get rejected in two different places:
• Trust/chain validation (untrusted CA, expired, etc.) → bad_certificate
• Signature algorithm negotiation, if the cert's key type/sig algorithm isn't in the server's CertificateRequest supported_signature_algorithms list at all → handshake_failure
My guess: His negative-test cert uses an algorithm (SHA-1, small RSA key, older curve) that was still negotiable in 17.0.18.8 but got disabled by default in 17.0.19.10 - so it now fails earlier in the handshake (negotiation) instead of later (actual cert validation).
To confirm:
1. Check the test cert's sig algorithm/key: openssl x509 -in badcert.pem -text -noout
2. Diff java.security between the two JDKs: grep -A5 "jdk.tls.disabledAlgorithms\|jdk.certpath.disabledAlgorithms" $JAVA_HOME/conf/security/java.security
3. Re-run with -Djavax.net.debug=ssl verbose and compare the CertificateRequest's signature_algorithms list between versions
4. Check the exception right before the alert in the debug log - CertificateException = real validation; NoSuchAlgorithmException/"no supported signature algorithm" = negotiation failure
If confirmed, this isn't something to "fix" - either update the negative-test cert to use a still-supported algorithm, or explicitly re-enable the specific algorithm via jdk.tls.disabledAlgorithms override if bad_certificate semantics are required for the test. Let me know if having comments.
------------------------------
ANGUO LI
------------------------------