☰
信创环境TLS握手失败:SSLHandshakeException handshake_failure排查与修复
2026/10/17 8:44:33 网站建设 项目流程

访问信创环境的服务器时,客户端直接抛一句话:javax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failure。这个报错我印象太深了,最近一个对接项目里,Java 8 的业务系统去调一台改造后的信创服务器上的 HTTPS 接口,连接刚建立就被对方“一票否决”。初次遇到的人容易一头雾水,以为是证书过期或者防火墙拦截,实际上这是 TLS 握手阶段双方没谈拢。这篇文章就把我的完整排查过程、修复方案和踩坑记录写出来,给正在做信创服务对接的开发、运维同学做个参考,尤其是那些需要跨环境调接口的场景,一次性把套路讲透。

1. 先搞清楚这个报错到底在说什么

1.1 TLS 握手里的一来一回

要说清handshake_failure,得先明白 TLS 握手的过程。简单说,客户端和服务端在建立安全连接时,要先“对暗号”:客户端发 ClientHello,把自己支持的 TLS 协议版本、密码套件列表、扩展点都报一遍;服务端收到后,从里面挑选一个自己也能接受的组合,回一个 ServerHello。如果服务端发现客户端列出的所有协议版本和密码套件,没有一个是自己能接受的,就会直接发一个 Alert 消息,handshake_failure对应的就是其中的 Alert 描述符,数值是 40。

这个动作有点像两个人第一次见面互相问“你会说什么语言”,一方报了一串方言,另一方全听不懂,那就没有继续谈的必要。问题在于,handshake_failure并不只有“方言不通”这一种触发条件,证书签名算法不被支持、服务端安全策略拒绝了某个扩展、SNI 信息缺失等,也可能在握手阶段以这个 Alert 收尾。所以排查的第一步,不是急着去改配置,而是确认到底卡在哪一步。

1.2 为什么偏偏在信创环境里高频出现

我在普通 Linux 服务器之间调接口很少遇到这类报错,但一旦涉及信创环境,频率明显高很多。技术层面的原因比较集中:改造后的服务器往往使用国内密码算法体系,也就是常说的国密算法 SM2、SM3、SM4,服务端默认仅启用国密密码套件,比如ECC-SM2-WITH-SM4-SM3、ECC-SM2-WITH-SM4-CBC-SM3这类。而标准 JDK 8 里内置的 JSSE 并不识别这些套件,客户端发出的 ClientHello 里根本不会携带它们,服务端一看列表里一个能用的都没有,直接回handshake_failure。

另外,信创服务器在安全策略上通常比传统环境更严格,只开 TLSv1.2 甚至 TLSv1.3,但不少旧业务系统还跑在 JDK 6 或 JDK 7 上,默认只支持 TLSv1.0、TLSv1.1。这种“版本代沟”也会触发同一个报错。再加上国密证书普遍由内部 CA 签发,Java 的cacerts信任库默认不包含这些根证书,信任链断裂同样会让握手失败。多种因素叠加,导致这类报错在信创对接场景里几乎成了“必修课”。

2. 定位问题,先别急着改配置

2.1 打开 JSSE 握手日志,把现场录下来

我最开始排查时犯过一个错误:看到handshake_failure就想是不是密码套件不匹配,直接去服务端加套件,结果根本不是那个问题。后来养成了习惯,第一步永远是开 JSSE 的握手日志,把现场完整录下来。

对 Java 应用,只要在启动参数里加上下面这段,就能把握手过程打出来:

-Djavax.net.debug=ssl:handshake:verbose

如果服务已经跑起来了不想重启,也可以退而求其次在目标主机上用抓包工具抓 TLS 报文,命令行版可以这样:

tcpdump -i any -w /tmp/tls.pcap host 10.0.0.5 and port 443

不过抓包需要 root 权限,很多时候现场不给开,所以最实用的还是让开发同学把 debug 日志重定向到文件里:

java -Djavax.net.debug=ssl:handshake:verbose -jar app.jar > /tmp/hs.log 2>&1

拿到日志后别只盯最后一行,要找到 ClientHello 和 ServerHello 的记录。如果日志显示客户端发出 ClientHello 后,服务端直接以fatal alert handshake_failure断开,那大概率是套件或协议版本不匹配。如果 ClientHello 之后服务端发来了 CertificateRequest,再往后才断,那就更可能是证书信任链或客户端证书的问题。这个判断方向能帮你省掉至少半天的瞎折腾。

2.2 三条命令探测服务端“听懂哪些方言”

Java 日志能看到客户端的视角,但服务端到底支持哪些协议版本和密码套件,还需要直接探测。我常用的第一条命令是openssl s_client,用指定协议版本和套件去连目标服务:

openssl s_client -connect 10.0.0.5:443 -tls1_2 -cipher 'ECDHE-RSA-AES128-GCM-SHA256' -brief </dev/null

如果这条能通,说明服务端至少支持国际标准套件ECDHE-RSA-AES128-GCM-SHA256,那么 Java 端默认配置理论上也能通,问题更可能在 JDK 的算法禁用列表或证书信任上。返回handshake failure的话,再换国密套件试:

openssl s_client -connect 10.0.0.5:443 -tls1_2 -cipher 'ECC-SM2-WITH-SM4-SM3:ECC-SM2-WITH-SM4-CBC-SM3' -brief </dev/null

这里要提醒一个细节:openssl s_client的-cipher参数只对 TLSv1.2 及以下生效,TLSv1.3 的套件要用-ciphersuites单独指定。另外,老版本的 openssl 不认识 SM2/SM3/SM4 相关套件,最好用 1.1.1 以上版本。如果手边没有 openssl,用 curl 也能凑合:

curl -v --tlsv1.2 --cipher ECDHE-RSA-AES128-GCM-SHA256 https://10.0.0.5/api

curl 能通而 Java 不通,问题基本就锁定在 Java 侧的配置上;两边都不通,才需要考虑服务端策略。

2.3 把证书信任链也一起查了

套件和协议没问题却仍然握手失败时,就要怀疑证书信任链。国密证书在内网环境里很常见,签发用的根 CA 常常是新建的私有 CA,而 JDK 自带的cacerts信任库并没有这个根证书。

检查方式也很简单,先用 openssl 看一下服务端实际下发的完整证书链:

openssl s_client -connect 10.0.0.5:443 -showcerts </dev/null

重点看链顶部的 Root CA 是不是内网自己签发的。如果是,就需要把这个根证书导入到调用方 JDK 的信任库:

keytool -import -trustcacerts -alias internal-root-ca -file rootCA.crt \ -keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit

注意changeit是 JDK 默认密码,生产环境如果改过要用实际密码,而且这个操作要评估安全性。还有一个坑:部分 JDK 版本对 SM2 算法的根证书支持并不好,keytool直接导入可能会报“无法识别的算法”,这种情况需要先把根证书转成 PEM 格式,或者用国密配套的证书工具处理,不能硬来。

3. 两类根因的修复实操

3.1 协议版本和算法策略不匹配的修法

如果探测下来服务端只开 TLSv1.3,而客户端是 JDK 8,那就只能从两个方向解决:要么升级客户端 JDK 到 11 或更高版本,要么让服务端在过渡期兼容 TLSv1.2。JDK 8 的 JSSE 实现不支持 TLSv1.3,不是靠调参数能解决的。

如果双方都在 TLSv1.2,问题就可能是 JDK 的算法禁用列表把某些套件过滤掉了。JDK 8 里默认的安全策略文件在:

$JAVA_HOME/jre/lib/security/java.security

里面有一项jdk.tls.disabledAlgorithms,默认禁用了不少老算法,比如3DES_EDE_CBC、RC4、TLSv1、TLSv1.1。正常情况下这些禁用是合理的,但如果服务端恰好只支持某种未被禁用的套件之外的东西,客户端列表里就找不到可用的选项。查看当前 JDK 实际支持的套件,可以写个几行的小程序跑一下:

import javax.net.ssl.SSLServerSocketFactory; import java.util.Arrays; public class ListCiphers { public static void main(String[] args) { Arrays.stream(((SSLServerSocketFactory) SSLServerSocketFactory.getDefault()) .getSupportedCipherSuites()).forEach(System.out::println); } }

如果列表里确实缺少服务端需要的套件,再回头检查是不是java.security里的disabledAlgorithms做了限制,或者 JVM 启动参数里强制指定了-Dhttps.protocols=TLSv1.0之类的低版本协议。清理掉这类错误的强制指定,往往问题就消失了。这里要特别提醒:不要为了图省事直接删除disabledAlgorithms里的条目,安全策略是保护系统底线的,正确做法是明确服务端到底支持哪几个套件,再按需调整。

3.2 国密密码套件缺失的修法

真正的硬骨头在国密套件。如果服务端只支持国密套件,而客户端是标准 JDK 8,那么无论你怎么调协议版本都没有用,因为客户端根本“不认识”这些套件。要让客户端支持国密,必须在 JVM 里引入支持国密的加密 Provider,并在创建 SSLContext 时使用这个 Provider。

目前通用的方案是引入 Bouncy Castle(简称 BC)开源加密库,它提供了国密算法和对应的 JSSE 实现。项目里需要引入bcprov和bcpkix相关依赖,版本要注意和 JDK 对应,我这边用的是 1.78 系列,JDK 8 对应的是bcprov-jdk18on这一类命名。然后写一个配置类,把 BC 的 Provider 加到 JVM 运行时:

import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import java.security.Security; public class GmSSLInitializer { public static void init() { // 注册底层算法 Provider Security.addProvider(new BouncyCastleProvider()); // 注册 JSSE Provider,提供国密套件的 SSLContext 实现 Security.addProvider(new BouncyCastleJsseProvider()); } }

注册之后还不能直接用 JDK 默认的SSLContext.getInstance("TLS"),那样拿到的还是 SunJSSE 创建的上下文。必须显式指定使用 BCJSSE Provider:

import javax.net.ssl.SSLContext; SSLContext ctx = SSLContext.getInstance("TLS", "BCJSSE");

之后加载客户端密钥库和信任库,构建 KeyManager 和 TrustManager:

import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.TrustManagerFactory; import java.io.FileInputStream; import java.security.KeyStore; KeyStore ks = KeyStore.getInstance("PKCS12"); try (FileInputStream in = new FileInputStream("/path/to/client.p12")) { ks.load(in, "your-password".toCharArray()); } KeyManagerFactory kmf = KeyManagerFactory.getInstance("PKIX", "BCJSSE"); kmf.init(ks, "your-password".toCharArray()); TrustManagerFactory tmf = TrustManagerFactory.getInstance("PKIX", "BCJSSE"); tmf.init(ks); // 如果是单独的信任库,这里传入信任库 ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new java.security.SecureRandom());

拿到这个SSLContext之后,把它设置成全局默认,方便已有 HTTP 客户端直接复用:

SSLContext.setDefault(ctx);

但全局设置影响面太大,我实际更推荐只在需要调用的那个 HTTP 客户端上生效。以 Apache HttpClient 为例,可以这样挂载:

import org.apache.http.config.Registry; import org.apache.http.config.RegistryBuilder; import org.apache.http.conn.socket.ConnectionSocketFactory; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.apache.http.client.HttpClient; Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create() .register("https", new SSLConnectionSocketFactory(ctx)) .build(); PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registry); HttpClient client = HttpClients.custom().setConnectionManager(cm).build();

用这种方式,只对指定的 HttpClient 生效,不影响系统里其他连接。Spring 的 RestTemplate 如果要走这个客户端,把HttpClient塞进HttpComponentsClientHttpRequestFactory就行。

3.3 客户端代码侧的正确修法

代码侧的修复,核心原则是“显式指定,不要依赖默认”。我见过不少人以为注册了 Provider 就万事大吉,结果测试还是报handshake_failure,原因就是没有把SSLContext.getInstance("TLS", "BCJSSE")真正用起来。注册 Provider 只是让 JVM 知道有这个东西,默认的 SSLContext 仍然由 SunJSSE 创建,国密套件依然不会出现在 ClientHello 里。

如果你用的是原生的HttpsURLConnection,可以在请求前设置:

HttpsURLConnection.setDefaultSSLSocketFactory(ctx.getSocketFactory());

注意 HostnameVerifier 也建议显式处理。国密证书的 SAN 通常做了域名绑定,如果用 IP 直连,即使 SSLContext 是对的,也会在校验 hostname 时报证书不匹配。生产环境不要用NoopHostnameVerifier直接跳过,正确做法是确保证书里包含你要访问的域名或 IP,或者在代码里使用访问域名而不是 IP。

另一个容易忽略的点是客户端证书。国密双向认证场景里,服务端要求客户端出示 SM2 证书,如果客户端的KeyManager没有正确加载含 SM2 私钥的密钥库,服务端会因为收不到合法的客户端证书而发送handshake_failure。这时 debug 日志里通常能看到CertificateRequest之后客户端没有回Certificate。遇到这种情况,重点不是调套件,而是确认密钥库里的证书链和私钥是否匹配。

4. 常见问题速查与避坑列表

4.1 最典型的几种报错组合

把这段时间遇到的各种情况整理一下,做成一个速查表,方便大家按症状查找:

现象根因快速排查方法修复方向
openssl 能连,Java 连不上JDK 算法禁用列表过滤了套件,或信任库缺根证书开 Java debug 看 ClientHello 列表,与 openssl 对比调整jdk.tls.disabledAlgorithms,或向 cacerts 导入根证书
服务端只支持 TLSv1.3,JDK8 报错协议版本最高只到 TLSv1.2java -version,查jdk.tls.client.protocols升级客户端 JDK 11+,或服务端兼容 TLSv1.2
ClientHello 里没有国密套件未正确注册并使用 BCJSSE Provider日志里看 ClientHello 的 cipher suites引入 BC 库,用SSLContext.getInstance("TLS", "BCJSSE")
报错附带 No appropriate protocol客户端可用协议列表为空,被策略限制查启动参数-Dhttps.protocols显式设置协议,如-Dhttps.protocols=TLSv1.2
已导入根证书仍报握手失败中间链路做了 TLS 终结,或访问方式与证书不匹配在应用本机直接探测,绕开负载均衡对齐证书域名,修正访问地址或网关配置
双向认证时客户端没回 Certificate密钥库未加载 SM2 私钥和证书链debug 日志看 CertificateRequest 后的流程修复 KeyManager 加载逻辑

表格里的最后一类尤其容易漏,我就在一个国产中间件对接项目里遇到过,服务端要求客户端证书,但客户端加载的 PKCS12 文件里只有公钥证书没有私钥,导致握手反复失败,日志一直指向套件问题,实际却卡在客户端证书上。

4.2 我踩过几次坑后总结的注意事项

第一,不要在生产环境直接开着-Djavax.net.debug=ssl:handshake:verbose长时间跑,这个输出量非常大,几百次并发请求就能把磁盘打满。正确的做法是临时开,复现一次就关,或者用 grep 把关键字过滤出来再落盘。

第二,不要为了快速恢复服务而盲目修改java.security的算法禁用列表。我之前有一次为了应急,把jdk.tls.disabledAlgorithms里的 3DES 放开了,结果被安全扫描工具直接标红。后来才明白,这个列表是安全底线,正确做法是让服务器端同时开放可接受的现代套件,而不是把客户端的底线降下来。

第三,导入 SM2 根证书时要注意 keytool 的兼容性。我自己的经验是,先用 openssl 把证书转成 PEM,再尝试用 keytool 导入;如果还是报算法错误,就换上国密配套的证书管理工具处理,不要反复用旧版本 JDK 的 keytool 硬试。

第四,信创环境里经常有负载均衡或多层网关,客户端连的是 VIP,实际协商可能在网关就终结了。所以当客户端和服务端的日志对不上时,要在两端各自原始节点上做探测,确认是不是中间设备把 TLS 流量“吃掉”了。

5. 把 TLS 兼容性检查做到前面去

5.1 一个顺手可用的探测脚本

吃过几次亏之后,我养成了一个习惯:对接信创服务的第一步,先跑一遍 TLS 兼容性探测,把服务端支持的协议版本和核心套件列出来,再决定客户端要不要做特殊改造。这里分享一个简单的 shell 函数,大家可以直接按需要改:

check_tls() { local host=$1 local port=${2:-443} for ver in tls1_2 tls1_3; do if openssl s_client -connect "$host:$port" "-$ver" -brief </dev/null >/dev/null 2>&1; then echo "[OK] $ver 可用" else echo "[--] $ver 不可用" fi done echo "--- 尝试常见国际套件 ---" for c in ECDHE-RSA-AES128-GCM-SHA256 ECDHE-RSA-AES256-GCM-SHA384; do if openssl s_client -connect "$host:$port" -tls1_2 -cipher "$c" -brief </dev/null >/dev/null 2>&1; then echo "[OK] $c" else echo "[--] $c" fi done } check_tls 10.0.0.5 443

这个脚本只覆盖最基础的项目,但对接前跑一遍,至少能判断出是协议版本问题、国际套件不通,还是要走国密专项。省下来的排查时间相当可观。

5.2 更稳的合作方式:双栈套件

从项目落地的角度,我在实际操作中最推荐的过渡期方案是“双栈套件”,就是让服务器在改造初期同时开放国际算法套件和国密算法套件,客户端优先使用国密,协商失败时再退回国际套件。这样做的好处是:老系统不用为了接一个新的信创服务而立刻升级 JDK 或者引入 BC 依赖,业务连续性更有保障。

当然,双栈只是一个过渡策略,最终如果业务有明确的国密合规要求,还是要推动客户端统一完成国密改造,把 Provider、密钥库、证书链全部标准化。我见过太多项目只改了服务端,客户端没跟上,结果一到正式联调就报handshake_failure,两边互相甩锅。其实只要在项目启动阶段把“TLS 版本 + 密码套件清单 + 证书信任链”这三样对齐,后面会顺畅很多。


我个人在实际排查中的体会是:handshake_failure这个报错本身只是结果,不是根因。遇到它,先别急着搜“如何修复”,而是按照“协议版本 -> 密码套件 -> 证书信任 -> 中间链路”这个顺序一层层剥开,每层都有对应的日志和命令去验证。最后的最后再分享一个小技巧:我会把openssl s_client的探测命令存成 shell 函数,随手就能用,对接新环境时先跑一遍,很多问题在别人还没开始调的时候就暴露了,这种前置检查带来的省心程度,谁用谁知道。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询