No appropriate protocol(protocol is disabled or cipher suites are inappropriate)

1,460 阅读6分钟

问题描述

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 JDK8U181mysql 5.7.268.0.20useSSL=true
Oracle JDK8U181mysql 5.7.308.0.20useSSL=true
Oracle JDK8U181mysql 5.7.268.0.32useSSL=true
Temurin OpenJDK8U362mysql 5.7.268.0.20useSSL=true×
Temurin OpenJDK8U362mysql 5.7.308.0.20useSSL=true
Temurin OpenJDK8U362mysql 5.7.268.0.32useSSL=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;
}

image.png

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 参数。