1. 概述

SSL(Secure Socket Layer,安全套接层)是一种用于保障网络通信安全的加密协议。本文将介绍可能导致 SSL 握手失败的各种场景,并分析其背后的原因和解决办法

如果你对 SSL 基础知识还不熟悉,可以先参考我们之前的 Java 中使用 JSSE 的 SSL 简介

2. 术语说明

由于 SSL 存在安全漏洞,目前已被 TLS(Transport Layer Security,传输层安全)所取代。然而,许多编程语言和库(包括 Java)仍然保留了对 SSL 的支持。

为了方便起见,在本文中,我们统一使用 SSL 来泛指这类加密协议。

3. 环境准备

为了演示 SSL 握手的过程,我们将使用 Java Socket API 编写一个简单的客户端与服务端通信程序,并模拟 SSL 连接。

3.1. 创建客户端与服务端

Java 中可以通过 Socket 实现客户端与服务端之间的网络通信。Socket 是 Java 安全套接字扩展(JSSE)的一部分。

我们先定义一个简单的服务端:

int port = 8443;
ServerSocketFactory factory = SSLServerSocketFactory.getDefault();
try (ServerSocket listener = factory.createServerSocket(port)) {
    SSLServerSocket sslListener = (SSLServerSocket) listener;
    sslListener.setNeedClientAuth(true);
    sslListener.setEnabledCipherSuites(
      new String[] { "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256" });
    sslListener.setEnabledProtocols(
      new String[] { "TLSv1.2" });
    while (true) {
        try (Socket socket = sslListener.accept()) {
            PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
            out.println("Hello World!");
        }
    }
}

这个服务端会向连接的客户端返回 "Hello World!"

接下来定义客户端:

String host = "localhost";
int port = 8443;
SocketFactory factory = SSLSocketFactory.getDefault();
try (Socket connection = factory.createSocket(host, port)) {
    ((SSLSocket) connection).setEnabledCipherSuites(
      new String[] { "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256" });
    ((SSLSocket) connection).setEnabledProtocols(
      new String[] { "TLSv1.2" });
    
    SSLParameters sslParams = new SSLParameters();
    sslParams.setEndpointIdentificationAlgorithm("HTTPS");
    ((SSLSocket) connection).setSSLParameters(sslParams);
    
    BufferedReader input = new BufferedReader(
      new InputStreamReader(connection.getInputStream()));
    return input.readLine();
}

该客户端将连接到服务端并读取返回的消息。

3.2. 在 Java 中生成证书

SSL 保证通信的机密性、完整性与身份认证。其中,证书在身份认证中起关键作用

通常我们会从证书颁发机构(CA)购买证书,但本文中使用自签名证书来演示。

使用 JDK 自带的 keytool 工具生成密钥库:

$ keytool -genkey -keypass password \
                  -storepass password \
                  -keystore serverkeystore.jks

该命令会引导你输入证书信息,如 CN(Common Name)、DN(Distinguished Name)等,最终生成包含服务端私钥和公钥证书的 serverkeystore.jks 文件。

⚠️ 注意:JKS 是 Java 特有的密钥库格式,现在建议使用 PKCS#12。

导出公钥证书:

$ keytool -export -storepass password \
                  -file server.cer \
                  -keystore serverkeystore.jks

然后将证书导入客户端的信任库:

$ keytool -import -v -trustcacerts \
                     -file server.cer \
                     -keypass password \
                     -storepass password \
                     -keystore clienttruststore.jks

至此,我们已经为服务端生成了密钥库,为客户端生成了信任库。

4. SSL 握手机制

SSL 握手是客户端与服务端建立信任关系并协商加密参数的过程。理解这一过程有助于快速定位握手失败的问题。

典型的 SSL 握手流程如下:

  1. 客户端发送支持的 SSL 版本和加密套件列表
  2. 服务端选择合适的版本和加密套件,并发送证书
  3. 客户端使用证书中的公钥加密“预主密钥”并发送
  4. 服务端使用私钥解密“预主密钥”
  5. 双方根据“预主密钥”计算出“共享密钥”
  6. 双方通过“共享密钥”交换确认消息

根据认证方式不同,握手分为 单向认证双向认证

4.1. 单向 SSL 握手

在单向认证中,仅客户端验证服务端证书,服务端不验证客户端身份。

✅ 优点:实现简单
❌ 风险:服务端信任所有连接客户端

4.2. 双向 SSL 握手

双向认证中,客户端和服务端互相验证对方证书

✅ 更安全
⚠️ 配置更复杂

5. 常见握手失败场景

为了更好地调试 SSL 握手失败问题,我们可以启用调试日志:

System.setProperty("javax.net.debug", "ssl:handshake");

5.1. 服务端缺少证书

启动 SimpleServer 后,客户端连接时报错:

Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
  Received fatal alert: handshake_failure

这是因为服务端未加载证书。解决方法是通过系统属性指定密钥库:

-Djavax.net.ssl.keyStore=serverkeystore.jks -Djavax.net.ssl.keyStorePassword=password

⚠️ 注意:路径必须为绝对路径或位于当前目录。

5.2. 服务端证书不被信任

重新运行后,客户端报错:

Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
  sun.security.validator.ValidatorException: 
  PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: 
  unable to find valid certification path to requested target

这是由于使用了自签名证书,客户端不信任该证书。

解决方法是将证书导入客户端的信任库:

-Djavax.net.ssl.trustStore=clienttruststore.jks -Djavax.net.ssl.trustStorePassword=password

⚠️ 不推荐生产环境使用自签名证书,应使用 CA 签发的证书。

5.3. 客户端证书缺失

运行后客户端报错:

Exception in thread "main" java.net.SocketException: 
  Software caused connection abort: recv failed

这是因为服务端要求客户端提供证书(双向认证),但客户端未配置。

为客户端和服务端分别配置证书和信任库:

服务端:

-Djavax.net.ssl.keyStore=serverkeystore.jks \
    -Djavax.net.ssl.keyStorePassword=password \
    -Djavax.net.ssl.trustStore=clienttruststore.jks \
    -Djavax.net.ssl.trustStorePassword=password

客户端:

-Djavax.net.ssl.keyStore=serverkeystore.jks \
    -Djavax.net.ssl.keyStorePassword=password \
    -Djavax.net.ssl.trustStore=clienttruststore.jks \
    -Djavax.net.ssl.trustStorePassword=password

✅ 成功输出:

Hello World!

5.4. 证书配置错误

证书中的 CN(Common Name)必须与服务端主机名一致,否则会报错:

Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
    java.security.cert.CertificateException: 
    No name matching localhost found

我们在客户端中启用了 HTTPS 的主机名验证:

SSLParameters sslParams = new SSLParameters();
sslParams.setEndpointIdentificationAlgorithm("HTTPS");
((SSLSocket) connection).setSSLParameters(sslParams);

✅ 建议始终启用主机名验证,以提升安全性。

5.5. SSL 协议版本不兼容

客户端与服务端必须使用兼容的协议版本,如 TLSv1.2。

若客户端设置为 TLSv1.1,而服务端使用 TLSv1.2,则握手失败:

((SSLSocket) connection).setEnabledProtocols(new String[] { "TLSv1.1" });

报错如下:

Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
  No appropriate protocol (protocol is disabled or cipher suites are inappropriate)

✅ 解决方法:检查并统一客户端与服务端的协议版本。

5.6. 加密套件不匹配

客户端和服务端必须使用兼容的加密套件。

若客户端使用不兼容的加密套件:

((SSLSocket) connection).setEnabledCipherSuites(
  new String[] { "TLS_RSA_WITH_AES_128_GCM_SHA256" });

则报错:

Exception in thread "main" javax.net.ssl.SSLHandshakeException: 
  Received fatal alert: handshake_failure

✅ 解决方法:确保双方至少有一个共同支持的加密套件。

6. 总结

本文通过 Java Socket 实现了 SSL 通信,介绍了单向和双向 SSL 握手机制,并列举了常见的握手失败场景及解决方案。

完整代码可参考 GitHub 示例项目


原始标题:SSL Handshake Failures | Baeldung