问题描述
Java 应用程序尝试与 MySQL Server 进行 SSL 握手,抛出异常:Caused by: javax.net.ssl.SSLHandshakeException: No appropriate protocol (protocol is disabled or cipher suites are inappropriate)
问题分析
控制变量法永远是排除问题的最好办法,于是我们做了六组实验,结果如下:
| JDK 版本 | MySQL Server 版本 | mysql-connector-java 版本 | SSL 配置 | 结果 |
|---|---|---|---|---|
| Oracle JDK8U181 | mysql 5.7.26 | 8.0.20 | useSSL=true | √ |
| Oracle JDK8U181 | mysql 5.7.30 | 8.0.20 | useSSL=true | √ |
| Oracle JDK8U181 | mysql 5.7.26 | 8.0.32 | useSSL=true | √ |
| Temurin OpenJDK8U362 | mysql 5.7.26 | 8.0.20 | useSSL=true | × |
| Temurin OpenJDK8U362 | mysql 5.7.30 | 8.0.20 | useSSL=true | √ |
| Temurin OpenJDK8U362 | mysql 5.7.26 | 8.0.32 | useSSL=true | √ |
JDK 支持的 TLS 版本:查阅资料,从 OpenJDK 8u292 和 11.0.11 开始,Java 应用程序,无论是服务器端,还是客户端,均默认禁止使用 TLS1.0 或 TLS1.1 协议去构建安全连接,参考资料:关于OpenJDK禁用TLS 1.0与1.1的分析
查看 JDK 可用的 SSL/TLS 协议,Java 应用程序作为 MySQL Server 的客户端,应该关注 Client enabled protocols 输出项。
try (SSLServerSocket serverSocket = (SSLServerSocket) SSLServerSocketFactory
.getDefault().createServerSocket()) {
System.out.println("##### Server supported protocols #####");
for (String protocol : serverSocket.getSupportedProtocols()) {
System.out.println(protocol);
}
System.out.println("##### Server enabled protocols #####");
for (String protocol : serverSocket.getEnabledProtocols()) {
System.out.println(protocol);
}
}
System.out.println();
try (SSLSocket socket = (SSLSocket) SSLSocketFactory
.getDefault().createSocket()) {
System.out.println("##### Client supported protocols #####");
for (String protocol : socket.getSupportedProtocols()) {
System.out.println(protocol);
}
System.out.println("##### Client enabled protocols #####");
for (String protocol : socket.getEnabledProtocols()) {
System.out.println(protocol);
}
}
Oracle JDK8U181:Client 默认支持 TLSv1,TLSv1.1,TLSv1.2。
##### Server supported protocols #####
SSLv2Hello
SSLv3
TLSv1
TLSv1.1
TLSv1.2
##### Server enabled protocols #####
SSLv2Hello
TLSv1
TLSv1.1
TLSv1.2
##### Client supported protocols #####
SSLv2Hello
SSLv3
TLSv1
TLSv1.1
TLSv1.2
##### Client enabled protocols #####
TLSv1
TLSv1.1
TLSv1.2
Temurin OpenJDK8U362:Client 默认支持 TLSv1.3,TLSv1.2。
##### Server supported protocols #####
TLSv1.3
TLSv1.2
TLSv1.1
TLSv1
SSLv3
SSLv2Hello
##### Server enabled protocols #####
TLSv1.3
TLSv1.2
SSLv2Hello
##### Client supported protocols #####
TLSv1.3
TLSv1.2
TLSv1.1
TLSv1
SSLv3
SSLv2Hello
##### Client enabled protocols #####
TLSv1.3
TLSv1.2
查看 MySQL Server 支持的 TLS 版本:SHOW VARIABLES LIKE 'tls_version'; 。
tls_version TLSv1,TLSv1.1,TLSv1.2
原因分析:Temurin OpenJDK8U362 版本默认只开启 TLSv1.3,TLSv1.2,MySQL Server 默认支持 TLSv1,TLSv1.1,TLSv1.2,按理说至少应该可以使用 TLSv1.2。事与愿违,偏偏 mysql 驱动版本为 8.0.20,建立 MySQL 连接时会报错:No appropriate protocol (protocol is disabled or cipher suites are inappropriate)。
Caused by: javax.net.ssl.SSLHandshakeException: No appropriate protocol (protocol is disabled or cipher suites are inappropriate)
at sun.security.ssl.HandshakeContext.<init>(HandshakeContext.java:171)
at sun.security.ssl.ClientHandshakeContext.<init>(ClientHandshakeContext.java:103)
at sun.security.ssl.TransportContext.kickstart(TransportContext.java:227)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:433)
at com.mysql.cj.protocol.ExportControlled.performTlsHandshake(ExportControlled.java:336)
at com.mysql.cj.protocol.StandardSocketFactory.performTlsHandshake(StandardSocketFactory.java:188)
at com.mysql.cj.protocol.a.NativeSocketConnection.performTlsHandshake(NativeSocketConnection.java:99)
at com.mysql.cj.protocol.a.NativeProtocol.negotiateSSLConnection(NativeProtocol.java:325)
... 129 common frames omitted
通过上述实验和分析,将问题定位到 mysql-connector-java 8.0.20 版本的 ExportControlled#performTlsHandshake() 方法,问题就出在 String[] allowedProtocols = getAllowedProtocols(pset, serverVersion, sslSocket.getSupportedProtocols()) 和 sslSocket.startHandshake() 中。
// Converts the socket being used in the given CoreIO to an SSLSocket by performing the SSL/TLS handshake.
// 在进行 ssl handshake 之前,java 客户端驱动程序就已经和 mysql 进行过通信,用于获取 ServerVersion,ServerDefaultCharset,ServerTimeZone 等信息。
public static Socket performTlsHandshake(Socket rawSocket, SocketConnection socketConnection, ServerVersion serverVersion)
throws IOException, SSLParamsException, FeatureNotAvailableException {
// 根据 mysql socketConnection 获取对应的属性设置,比如 JDBC 连接中配置的 useUnicode,useSSL,characterEncoding,autoReconnect,serverTimezone 等参数
PropertySet pset = socketConnection.getPropertySet();
// "useSSL=true", "requireSSL=false", "verifyServerCertificate=false"} is translated to "sslMode=PREFERRED"
SslMode sslMode = pset.<SslMode>getEnumProperty(PropertyKey.sslMode).getValue();
// 默认不验证 mysql server 的 CA 证书
boolean verifyServerCert = sslMode == SslMode.VERIFY_CA || sslMode == SslMode.VERIFY_IDENTITY;
KeyStoreConf trustStore = !verifyServerCert ? new KeyStoreConf()
: getTrustStoreConf(pset, PropertyKey.trustCertificateKeyStoreUrl, PropertyKey.trustCertificateKeyStorePassword,
PropertyKey.trustCertificateKeyStoreType, verifyServerCert && serverVersion == null);
KeyStoreConf keyStore = getKeyStoreConf(pset, PropertyKey.clientCertificateKeyStoreUrl, PropertyKey.clientCertificateKeyStorePassword,
PropertyKey.clientCertificateKeyStoreType);
// 根据配置构建 socketFactory,socketFactory 用于生产 SSLSocket 对象
SSLSocketFactory socketFactory = getSSLContext(keyStore.keyStoreUrl, keyStore.keyStoreType, keyStore.keyStorePassword, trustStore.keyStoreUrl,
trustStore.keyStoreType, trustStore.keyStorePassword, serverVersion != null, verifyServerCert,
sslMode == PropertyDefinitions.SslMode.VERIFY_IDENTITY ? socketConnection.getHost() : null, socketConnection.getExceptionInterceptor())
.getSocketFactory();
// 使用 socketFactory 构建 sslSocket,用于 ssl 握手
SSLSocket sslSocket = (SSLSocket) socketFactory.createSocket(rawSocket, socketConnection.getHost(), socketConnection.getPort(), true);
// 虽然 Temurin OpenJDK8U362 默认禁用了 TLS1.0 和 TLS1.1,但 sslSocket.getSupportedProtocols() 方法还是会返回 [TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, SSLv3, SSLv2Hello]
// 获取 mysql server 支持的 tls 版本,8.0.20 版本在这里有 bug,获取到的协议版本只有 TLS1.0 和 TLS1.1
String[] allowedProtocols = getAllowedProtocols(pset, serverVersion, sslSocket.getSupportedProtocols());
sslSocket.setEnabledProtocols(allowedProtocols);
// 获取 JDK 支持的密码套件(ciphers)
String[] allowedCiphers = getAllowedCiphers(pset, Arrays.asList(sslSocket.getEnabledCipherSuites()));
if (allowedCiphers != null) {
sslSocket.setEnabledCipherSuites(allowedCiphers);
}
// Temurin OpenJDK8U362 默认禁用了 TLS1.0 和 TLS1.2,在构建 ClientHandshakeContext 对象时会报错,此时还没有进行 Handshake,仅仅是在 JDK 内部代码进行校验就抛错了
sslSocket.startHandshake();
return sslSocket;
}
ExportControlled#getAllowedProtocols() 方法: 获取 mysql server 支持的 tls 版本,8.0.20 版本在这里有 bug,获取到的协议版本只有 TLS1.0 和 TLS1.1
private static final String[] TLS_PROTOCOLS = new String[] { TLSv1_3, TLSv1_2, TLSv1_1, TLSv1 };
private static String[] getAllowedProtocols(PropertySet pset, ServerVersion serverVersion, String[] socketProtocols) {
String[] tryProtocols = null;
// If enabledTLSProtocols configuration option is set, overriding the default TLS version restrictions.
// This allows enabling TLSv1.2 for self-compiled MySQL versions supporting it, as well as the ability
// for users to restrict TLS connections to approved protocols (e.g., prohibiting TLSv1) on the client side.
// 如果有显示指定的 TLS 版本,则使用用户指定的
String enabledTLSProtocols = pset.getStringProperty(PropertyKey.enabledTLSProtocols).getValue();
if (enabledTLSProtocols != null && enabledTLSProtocols.length() > 0) {
tryProtocols = enabledTLSProtocols.split("\s*,\s*");
}
// It is problematic to enable TLSv1.2 on the client side when the server is compiled with yaSSL. When client attempts to connect with
// TLSv1.2 yaSSL just closes the socket instead of re-attempting handshake with lower TLS version. So here we allow all protocols only
// for server versions which are known to be compiled with OpenSSL.
// 如果无法获取到 serverVersion,则使用 TLS_PROTOCOLS 中规定的协议进行协商
else if (serverVersion == null) {
// X Protocol doesn't provide server version, but we prefer to use most recent TLS version, though it also mean that X Protocol
// connection to old MySQL 5.7 GPL releases will fail by default, user must use enabledTLSProtocols=TLSv1.1 to connect them.
tryProtocols = TLS_PROTOCOLS;
}
// 否则根据 serverVersion 进行判断,如果满足以下条件,则使用 TLS_PROTOCOLS 中规定的协议进行协商
else if (serverVersion.meetsMinimum(new ServerVersion(5, 7, 28))
|| serverVersion.meetsMinimum(new ServerVersion(5, 6, 46)) && !serverVersion.meetsMinimum(new ServerVersion(5, 7, 0))
|| serverVersion.meetsMinimum(new ServerVersion(5, 6, 0)) && Util.isEnterpriseEdition(serverVersion.toString())) {
tryProtocols = TLS_PROTOCOLS;
}
// mysql server 5.7.26 不满足以上条件,进入此分支,将尝试使用 TLS1.0 和 TLS1.1 协议进行连接
else {
// allow only TLSv1 and TLSv1.1 for other server versions by default
tryProtocols = new String[] { TLSv1_1, TLSv1 };
}
List<String> configuredProtocols = new ArrayList<>(Arrays.asList(tryProtocols));
List<String> jvmSupportedProtocols = Arrays.asList(socketProtocols);
List<String> allowedProtocols = new ArrayList<>();
for (String protocol : TLS_PROTOCOLS) {
if (jvmSupportedProtocols.contains(protocol) && configuredProtocols.contains(protocol)) {
allowedProtocols.add(protocol);
}
}
// mysql server 5.7.26 对应的 allowedProtocols 为 [TLSv1.1, TLSv1]
return allowedProtocols.toArray(new String[0]);
}
获取 mysql server 支持的 tls 协议版本之后,就调用 sslSocket.startHandshake() 方法尝试 ssl handshake。沿着异常栈帧找到 TransportContext#kickstart() 方法:Java 应用作为 mysql server 的客户端,sslConfig.isClientMode 为 true,会初始化 ClientHandshakeContext 对象。
void kickstart() throws IOException {
if (isUnsureMode) {
throw new IllegalStateException("Client/Server mode not yet set.");
}
// The threshold for allowing the method to continue processing
// depends on whether we are doing a key update or kickstarting
// a handshake. In the former case, we only require the write-side
// to be open where a handshake would require a full duplex connection.
boolean isNotUsable = outputRecord.writeCipher.atKeyLimit() ?
(outputRecord.isClosed() || isBroken) :
(outputRecord.isClosed() || inputRecord.isClosed() || isBroken);
if (isNotUsable) {
if (closeReason != null) {
throw new SSLException(
"Cannot kickstart, the connection is broken or closed",
closeReason);
} else {
throw new SSLException(
"Cannot kickstart, the connection is broken or closed");
}
}
// initialize the handshaker if necessary
if (handshakeContext == null) {
// TLS1.3 post-handshake
if (isNegotiated && protocolVersion.useTLS13PlusSpec()) {
handshakeContext = new PostHandshakeContext(this);
} else {
// Java 应用作为 mysql server 的客户端,sslConfig.isClientMode 为 true,会初始化 ClientHandshakeContext 对象
handshakeContext = sslConfig.isClientMode ?
new ClientHandshakeContext(sslContext, this) :
new ServerHandshakeContext(sslContext, this);
}
}
// kickstart the handshake if needed
//
// Need no kickstart message on server side unless the connection
// has been established.
if (isNegotiated || sslConfig.isClientMode) {
handshakeContext.kickstart();
}
}
初始化 ClientHandshakeContext 对象会触发父类 HandshakeContext 构造方法:由于 getAllowedProtocols() 返回 mysql server 支持的协议版本为 TLS1.0 和 TLS1.1,Temurin OpenJDK8U362 默认禁用了 TLS1.0 和 TLS1.1,getActiveProtocols() 方法获取到的 activeProtocols 集合为空。
protected HandshakeContext(SSLContextImpl sslContext,
TransportContext conContext) throws IOException {
this.sslContext = sslContext;
this.conContext = conContext;
this.sslConfig = (SSLConfiguration)conContext.sslConfig.clone();
this.algorithmConstraints = new SSLAlgorithmConstraints(
sslConfig.userSpecifiedAlgorithmConstraints);
// 获取可用的 ssl 协议,由于 getAllowedProtocols() 返回 mysql server 支持的协议版本为 TLS1.0 和 TLS1.1,Temurin OpenJDK8U362 默认禁用了 TLS1.0 和 TLS1.1,getActiveProtocols() 方法获取到的 activeProtocols 集合为空
this.activeProtocols = getActiveProtocols(sslConfig.enabledProtocols,
sslConfig.enabledCipherSuites, algorithmConstraints);
// activeProtocols 集合为空,抛出 SSLHandshakeException
if (activeProtocols.isEmpty()) {
throw new SSLHandshakeException(
"No appropriate protocol (protocol is disabled or " +
"cipher suites are inappropriate)");
}
// ...
HandshakeContext#getActiveProtocols() 方法:
private static List<ProtocolVersion> getActiveProtocols(
List<ProtocolVersion> enabledProtocols,
List<CipherSuite> enabledCipherSuites,
AlgorithmConstraints algorithmConstraints) {
boolean enabledSSL20Hello = false;
ArrayList<ProtocolVersion> protocols = new ArrayList<>(4);
for (ProtocolVersion protocol : enabledProtocols) {
if (!enabledSSL20Hello && protocol == ProtocolVersion.SSL20Hello) {
enabledSSL20Hello = true;
continue;
}
// 将 TLS1.0 和 TLS1.1 协议传入 SSLAlgorithmConstraints#permits() 会返回 false,紧接着执行 continue,结果就是找不到可用的 ssl 协议
if (!algorithmConstraints.permits(
EnumSet.of(CryptoPrimitive.KEY_AGREEMENT),
protocol.name, null)) {
// Ignore disabled protocol.
continue;
}
//...
SSLAlgorithmConstraints#permits() 方法:Temurin OpenJDK8U362 默认禁用了 TLS1.0 和 TLS1.2,执行 tlsDisabledAlgConstraints.permits() 方法后 permitted 变为 0,表示不支持该协议。
@Override
public boolean permits(Set<CryptoPrimitive> primitives,
String algorithm, AlgorithmParameters parameters) {
boolean permitted = true;
if (peerSpecifiedConstraints != null) {
permitted = peerSpecifiedConstraints.permits(
primitives, algorithm, parameters);
}
if (permitted && userSpecifiedConstraints != null) {
permitted = userSpecifiedConstraints.permits(
primitives, algorithm, parameters);
}
if (permitted) {
// Temurin OpenJDK8U362 默认禁用了 TLS1.0 和 TLS1.2,执行 tlsDisabledAlgConstraints.permits() 方法后 permitted 变为 0,表示不支持该协议
permitted = tlsDisabledAlgConstraints.permits(
primitives, algorithm, parameters);
}
if (permitted && enabledX509DisabledAlgConstraints) {
permitted = x509DisabledAlgConstraints.permits(
primitives, algorithm, parameters);
}
return permitted;
}
mysql-connector-java 8.0.32 版本的 ExportControlled#getAllowedProtocols() 方法代码:少了根据 MySQL Server 版本号设置 TLS 版本的脑抽代码,具体逻辑为:如果有显示指定的 TLS 版本,则使用用户指定的;没有则默认选择 TLS1.3 和 TLS1.2。
private static final String[] VALID_TLS_PROTOCOLS = new String[] { TLSv1_3, TLSv1_2 };
private static String[] getAllowedProtocols(PropertySet pset, @SuppressWarnings("unused") ServerVersion serverVersion, String[] socketProtocols) {
List<String> tryProtocols = null;
// 获取显示指定的 TLS 版本,则使用用户显示指定的 TLS 版本
RuntimeProperty<String> tlsVersions = pset.getStringProperty(PropertyKey.tlsVersions);
if (tlsVersions != null && tlsVersions.isExplicitlySet()) {
// If tlsVersions configuration option is set then override the default TLS versions restriction.
if (tlsVersions.getValue() == null) {
throw ExceptionFactory.createException(SSLParamsException.class,
"Specified list of TLS versions is empty. Accepted values are TLSv1.2 and TLSv1.3.");
}
tryProtocols = getValidProtocols(tlsVersions.getValue().split("\s*,\s*"));
} else {
// 如果没有显示指定 TLS 版本,则默认使用 VALID_TLS_PROTOCOLS,该数组包含 TLS1.3 和 TLS1.2
tryProtocols = new ArrayList<>(Arrays.asList(VALID_TLS_PROTOCOLS));
}
List<String> jvmSupportedProtocols = Arrays.asList(socketProtocols);
List<String> allowedProtocols = new ArrayList<>();
for (String protocol : tryProtocols) {
if (jvmSupportedProtocols.contains(protocol)) {
allowedProtocols.add(protocol);
}
}
return allowedProtocols.toArray(new String[0]);
}
问题解决
- 升级 MySQL 驱动版本,mysql-connector-java 8.0.20 关于 TLS 协议版本判断的代码,写的有点脑抽。
- 升级 MySQL Server 的版本,如果 MySQL Server 版本大于等于 5.7.28,就可以协商使用 TLS1.2 进行通信。
- 去掉 JDBC 连接中的 useSSL=true 配置,舍弃协议加密功能。
- 在 JDBC 连接中加上 enabledTLSProtocols=TLSv1.2 配置,显示指定使用 TLS1.2 版本进行通信。
扩展
对于 sslMode 参数,mysql 驱动中有这样一段描述:By default, network connections are SSL encrypted; this property permits secure connections to be turned off, or a different levels of security to be chosen. The following values are allowed: "DISABLED" - Establish unencrypted connections; "PREFERRED" - (default) Establish encrypted connections if the server enabled them, otherwise fall back to unencrypted connections; "REQUIRED" - Establish secure connections if the server enabled them, fail otherwise; "VERIFY_CA" - Like "REQUIRED" but additionally verify the server TLS certificate against the configured Certificate Authority (CA) certificates; "VERIFY_IDENTITY" - Like "VERIFY_CA", but additionally verify that the server certificate matches the host to which the connection is attempted.[CR] This property replaced the deprecated legacy properties "useSSL", "requireSSL", and "verifyServerCertificate", which are still accepted but translated into a value for "sslMode" if "sslMode" is not explicitly set: "useSSL=false" is translated to "sslMode=DISABLED"; {"useSSL=true", "requireSSL=false", "verifyServerCertificate=false"} is translated to "sslMode=PREFERRED"; {"useSSL=true", "requireSSL=true", "verifyServerCertificate=false"} is translated to "sslMode=REQUIRED"; {"useSSL=true" AND "verifyServerCertificate=true"} is translated to "sslMode=VERIFY_CA". There is no equivalent legacy settings for "sslMode=VERIFY_IDENTITY". Note that, for ALL server versions, the default setting of "sslMode" is "PREFERRED", and it is equivalent to the legacy settings of "useSSL=true", "requireSSL=false", and "verifyServerCertificate=false", which are different from their default settings for Connector/J 8.0.12 and earlier in some situations. Applications that continue to use the legacy properties and rely on their old default settings should be reviewed.[CR] The legacy properties are ignored if "sslMode" is set explicitly. If none of "sslMode" or "useSSL" is set explicitly, the default setting of "sslMode=PREFERRED" applies.
对于 useSSL 参数,mysql 驱动中有这样一段描述:For 8.0.12 and earlier: Use SSL when communicating with the server (true/false), default is 'true' when connecting to MySQL 5.5.45+, 5.6.26+ or 5.7.6+, otherwise default is 'false'.[CR] For 8.0.13 and later: Default is 'true'. DEPRECATED. See sslMode property description for details. MySQL 驱动程序在 8.0.13 版本之后将 useSSL 标记为废弃状态,推荐使用 sslMode 参数。