在一口气把 GmSSL 的命令行 s_server、s_client 跑通之后,很多人会觉得剩下的工作就是"把它搬到 Java 里",然后信心满满地在 Netty 项目里写下SslContextBuilder.forClient().build(),结果第一次握手就卡死或者直接抛SSLHandshakeException。问题的根子不在 GmSSL,也不在 Netty,而在于Netty 的 SslHandler 只认 JDK 的SSLEngine这一个接口,而 JDK 自带的 TLS 实现根本不认识国密套件。本文是"死磕 GMSSL 通信"系列的第二篇,上一篇解决的是原生库编译、证书签发和命令行互通,这一篇专门讲 Java 侧怎么把国密 TLS 塞进 Netty 的通道模型里:证书怎么读、SslContext怎么自定义、SSLEngine的状态机哪些地方最容易写错、粘包到底该算在哪一层、握手失败怎么定位、SM2 的算力开销该怎么摊薄。内容偏实战,适合已经能把国密证书签出来、准备在 Java 服务里落地国密通道的读者,也适合被"国密通信收到乱码"折磨过一轮、想搞清楚分层边界的人。
1. 国密握手和 Netty 的 SslHandler 之间,错位到底在哪
1.1 Netty 对外只暴露一个接口:SSLEngine
把 Netty 的 TLS 相关源码翻一遍会发现,SslContext是个抽象类,真正干活的是它newEngine出来的SSLEngine,而SslHandler内部就是一个wrap/unwrap的循环调度器。它不关心底层是 OpenSSL 的 JNI 封装,还是纯 Java 实现,只要这个SSLEngine老老实实遵守 JSSE 定义的那套语义,SslHandler 就能把数据正确地在 pipeline 里搬运。
这个设计其实对我们是个好消息:"集成国密"这件事可以精确地化简为"给 Netty 造一个能跑国密套件的 SSLEngine",业务层的ChannelHandler、编解码器、心跳检测全都不用改。反过来讲,如果一开始就想着"我要在 pipeline 里插一个加解密 Handler",那就会掉进自己实现 TLS record 层的深坑,后面我会说明为什么这条路的成本被严重低估了。
那为什么不用 JDK 自带的?因为SSLContext.getInstance("TLS")默认拿到的 SunJSSE 实现,它的 supported cipher suites 列表里没有 SM 系列。有人会想"我注册个 BouncyCastleProvider 不就行了"——不行。JSSE 的 Provider 机制要求某个 provider 实现SSLContextSpi,而bcprov这个包里根本不提供 JSSE 层的实现,它只提供算法(SM2/SM3/SM4 这些KeyFactory、Cipher、Signature的 SPI)。算法层能用,协议层没接上,这是两个不同层次的扩展点。
BC 的 TLS 协议实现放在另一个 artifact 里,同时还带了一个 JSSE Provider 实现。理论上注册它之后,SSLContext.getInstance("TLS", "BCJSSE")就能拿到一个标准的SSLEngine,Netty 侧一行代码不用改。我们最初就是冲着这条路去的,实测下来有两个门槛:一是它的 supported cipher suites 里 SM 套件是否出现在列表中,和版本强相关,必须自己跑一遍打印确认;二是国密握手常用的双证书组织方式,跟 JSSE 默认的单证书假设之间有缝。所以第一件事不是写代码,而是写个十行的探测程序把 supported 列表打出来,别靠猜。
1.2 国密握手里的双证书与曲线运算
RSA 体系下,服务端通常只有一张证书、一对密钥,签名和密钥交换共用。国密 TLS 的典型形态是双证书:一张签名证书负责身份认证和签名操作,一张加密证书负责密钥交换相关的运算。这个差异会一路影响到 Java 侧的对象模型——你不能只有一个KeyManager,得能按用途把两套密钥区分开;服务端在Certificate消息里要按约定顺序把两张证书(以及各自的链)发出去;客户端校验证书时也要按用途分别处理。
算法上,握手阶段涉及的非对称运算包括:服务端用 SM2 私钥对握手参数签名,客户端用 SM2 公钥验签,密钥交换环节还要做一次基于 SM2 曲线的运算。摘要全部走 SM3,对称加密走 SM4 的 CBC 或 GCM 模式。也就是说,一条完整的国密握手,非对称运算的次数明显多于 RSA 单证书场景,这直接影响后面的连接策略选型——短连接高频握手在国密场景下是纯粹的浪费。
这里有个容易被忽略的细节:协议版本号。国密 TLS 常见的实现基于 TLS 1.1/1.2 的记录层结构,但套件名和部分扩展跟标准 IANA 套件不重合;而较新的做法走 RFC 8998 定义的那两条 SM4 套件,挂在 TLS 1.3 上。两套东西的记录层版本号、密钥导出方式都不一样,你的客户端和服务端必须在同一套体系里,混用会出现"套件名对得上但握手死活不通"的现象,排查时非常费劲。
1.3 三条可选路线,我为什么选中间那条
落地之前我们对三条路线做了评估,结论写在下面这张表里,供你按自己的场景取舍:
| 路线 | 实现方式 | 实现成本 | 性能 | 部署复杂度 | 适合场景 |
|---|---|---|---|---|---|
| A. JNI 调原生库 | 通过 JNI 调用原生国密库的 SSL 接口 | 中,但要处理 JNI 内存与线程模型 | 高,接近原生 | 高,每台机器都要带对应平台的动态库 | 对吞吐极端敏感、能统一管控部署环境 |
| B. 纯 Java 协议栈 + 自定义 SSLEngine | 用纯 Java 的 TLS 实现包一层,适配到SSLEngine | 中高,状态机调试耗时 | 中,SM2 运算纯 Java 有损耗 | 低,一个 jar 走天下 | 绝大多数 Java 服务端,尤其是容器化部署 |
| C. 自己实现 record 层加解密 | 在 pipeline 里插加解密 Handler | 表面低,实际极高 | 中 | 低 | 只在协议被裁剪到极简、且团队有密码学背景时考虑 |
路线 C 是最容易误判的一条。看起来"不就是拿 SM4 加个密吗",但 TLS 不是"加个密"这么简单:密钥导出函数、序列号参与 MAC 的计算、record 分片、重协商、alert 消息、close_notify,任何一环处理不对,表现出的症状都是"偶尔解密失败"或者"压测时才出问题",这类 bug 的定位成本远超初期省下的开发时间。而且只要你自己碰了 record 层,就等于自己承担了全部的密码学正确性责任。
我们最终选了路线 B。核心原因是部署形态:服务跑在容器里,镜像要能跨架构,JNI 方案要维护多个平台的动态库并保证版本一致,运维成本和排障成本都上去了。纯 Java 方案牺牲了一部分性能,但换来了可预期的部署和可调试性——出问题的时候能直接在 IDE 里打断点,这个价值在项目初期比几个百分点的吞吐重要得多。
2. SM2 证书链的加载:JDK 的 KeyStore 认不出国密 OID
2.1 先认清手上那堆文件到底是什么
服务端给你的通常不是一个 keystore,而是一堆 PEM 文件:签名证书、签名私钥、加密证书、加密私钥,可能再加一两个中间 CA 证书。先用文本编辑器看一眼每个文件的头部,常见的就那么几种:
-----BEGIN CERTIFICATE-----:标准 X.509 证书,对应 Java 的X509Certificate。-----BEGIN PRIVATE KEY-----:PKCS#8 格式的未加密私钥,内容是一段 DER。-----BEGIN EC PRIVATE KEY-----:SEC1 格式的私钥,通常自带曲线参数。-----BEGIN ENCRYPTED PRIVATE KEY-----:PKCS#8 加密私钥,一般用 PBES2 派生密钥来保护。
第一个坑就出现在这一步:JDK 的keytool -printcert打不开国密证书,会甩出unrecognized algorithm之类的错误。这不是文件坏了,而是 JDK 内置的算法集不认国密签名算法的 OID,解析证书的签名算法字段时就卡住了。用原生工具去 parse 才能看到真实内容。我在项目里第一次遇到这个错误时,花了半小时怀疑是证书签发脚本写错了,最后发现问题只是用错了工具。
注意:不要因为
keytool报错就去重新签发证书,先换解析工具确认文件本身是否正常,这一步能省掉大量无效重签。
2.2 用 BC 把 SM2 私钥读成 PrivateKey
读完文件形态之后,代码其实很固定。标准做法是注册一次 BC Provider,然后用 PEM 解析器读对象,再根据对象类型分别转换。核心代码大概长这样:
static { if (Security.getProvider("BC") == null) { Security.addProvider(new BouncyCastleProvider()); } } public static PrivateKey loadPrivateKey(String pemPath) throws Exception { try (PEMParser parser = new PEMParser(new FileReader(pemPath))) { Object obj = parser.readObject(); JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); if (obj instanceof PEMKeyPair) { // SEC1 格式:文件里带曲线参数 return converter.getKeyPair((PEMKeyPair) obj).getPrivate(); } if (obj instanceof PrivateKeyInfo) { // PKCS#8 未加密 return converter.getPrivateKey((PrivateKeyInfo) obj); } if (obj instanceof PKCS8EncryptedPrivateKeyInfo) { // PKCS#8 加密,需要先用密码解出 PrivateKeyInfo PKCS8EncryptedPrivateKeyInfo enc = (PKCS8EncryptedPrivateKeyInfo) obj; InputDecryptorProvider dec = new JceOpenSSLPKCS8DecryptorProviderBuilder() .setProvider("BC") .build(password.toCharArray()); return converter.getPrivateKey(enc.decryptPrivateKeyInfo(dec)); } throw new IllegalArgumentException("不认识 PEM 内容: " + obj.getClass()); } }这里有三个我自己踩过的点。第一,PEMKeyPair和PrivateKeyInfo不能混着处理,前者再调getPrivateKey(PrivateKeyInfo)会直接类型转换失败;用instanceof分支是最省事的写法,别指望一个方法吃下所有格式。第二,证书的加载用CertificateFactory.getInstance("X.509", "BC"),必须显式指定 provider,默认 provider 解析国密签名算法会失败。第三,不要用equals去比较两个来源不同的 SM2 私钥对象,一个是 SEC1 读出来的,一个是 PKCS#8 读出来的,即使数学上等价,对象实现也不同,判等大概率返回 false。要校验密钥是否一致,正确做法是各取一次公钥的编码字节数组做比较。
2.3 双证书的别名与顺序,别靠"第一个"取
如果你走的是 JSSE 风格的路子,需要把密钥和证书组装进KeyStore,那下面这两条必须刻在脑子里。
第一,setKeyEntry时证书链的顺序是叶子在前、CA 在后。顺序反了,握手时对端拿到的链无法构建到信任根,报出来的错误往往只是笼统的握手失败,看不出是哪张证书的问题。第二,双证书场景下签名证书和加密证书各占一个别名,代码里绝对不能用"取第一个别名"这种方式去拿密钥,因为不同版本的 keytool 或不同工具生成的 keystore,别名排序可能不一样,这个 bug 在你本地跑得好好的,换台机器就复现。
我的做法是给两套材料约定固定的别名常量(比如sign和enc),加载时按别名精确取;同时在启动阶段加一段自检逻辑,把取出来的证书的用途、有效期、公钥算法打印一遍,一旦配错在启动时就暴露,而不是等到生产环境握手失败才去翻日志。
3. 自定义 SslContext 和 SSLEngine 的落地骨架
3.1 继承 SslContext,真正要写的没几个方法
SslContext是个抽象类,但它的抽象方法数量比想象中少。实际要覆写的主要是这几个:isClient()、newEngine(ByteBufAllocator)、newEngine(ByteBufAllocator, String, int)、sessionContext()、applicationProtocolNegotiator()。newHandler系列通常有默认实现,最终会落到new SslHandler(engine)上;如果你用的版本里它是抽象的,照着new SslHandler(newEngine(alloc))写就行。
骨架大致是这样:
public class GmSslContext extends SslContext { private final boolean client; private final GmSslConfig config; public GmSslContext(boolean client, GmSslConfig config) { this.client = client; this.config = config; } @Override public boolean isClient() { return client; } @Override public SSLEngine newEngine(ByteBufAllocator alloc) { return new GmSslEngine(client, config, null, -1); } @Override public SSLEngine newEngine(ByteBufAllocator alloc, String peerHost, int peerPort) { // peerHost 一定要透传下去,后面做主机名校验要用 return new GmSslEngine(client, config, peerHost, peerPort); } @Override public SSLSessionContext sessionContext() { // 自己实现一个空的 SessionContext 即可,不要随便返回 null return config.sessionContext(); } @Override public ApplicationProtocolNegotiator applicationProtocolNegotiator() { return null; } @Override public SslHandler newHandler(ByteBufAllocator alloc) { // 关键:传入 delegatedTaskExecutor,避免握手运算占满 EventLoop return new SslHandler(newEngine(alloc), config.delegatedTaskExecutor()); } }那个peerHost参数看着不起眼,实际很重要。标准 JSSE 的SSLEngine在beginHandshake时会根据peerHost自动做 endpoint identification,也就是主机名校验。你自己写的 engine 不会自动做这件事,如果又把这个参数丢了,最后的结果是握手能成功、数据也能通,但完全没有校验对端身份——一个静默的安全缺陷。
3.2 wrap/unwrap 状态机里最容易写错的四个点
这块是整个集成工作里最耗时间的部分。SslHandler会不停调用你的unwrap和wrap,并根据返回的SSLEngineResult决定下一步动作,一旦语义对不上,表现出的症状千奇百怪。
第一,BUFFER_UNDERFLOW的语义必须是"数据不够,等更多数据"。如果你在 record 不完整的时候误报OK,上层会以为解析出了一个完整记录,拿半个字节流去解密,报出来的错是bad record mac。这个错误极具误导性,会让人往密钥派生的方向去查,其实根源是分帧。我的建议是:把src里能读的字节全部搬进自己的内部缓冲区,consumed报实际搬走的字节数,解析时发现不完整就返回BUFFER_UNDERFLOW。最怕的是"一半读进缓冲、一半留在src",两边都算不清,会出现偶尔丢一个字节的诡异现象,而这类 bug 只在特定包长下复现。
第二,BUFFER_OVERFLOW必须能正确报告所需空间。SslHandler拿到溢出结果后会尝试扩容目标ByteBuf再重试,前提是你通过engine.getSession().getApplicationBufferSize()和getPacketBufferSize()报出的值足够大。这两个值如果返回得太保守,Netty 会反复扩容甚至触发内存泄漏告警;返回得离谱大,内存占用又下不来。经验值是包缓冲区按"最大 record 长度 + 余量"来定,应用缓冲区按业务最大单帧来定。
第三,HandshakeStatus的转换不能漏。NEED_WRAP、NEED_UNWRAP、NEED_TASK、FINISHED这几个状态是有严格顺序的,漏掉NEED_TASK会让握手永久卡在中间,表现是连接建立了但一直发不出数据。特别注意FINISHED之后要正确切换到应用数据阶段,never状态不能提前返回。
第四,closeOutbound之后还得能产出一条 alert 记录。很多人实现了握手和数据加解密就以为完事了,结果优雅关闭时对端收到的是 TCP 断开而不是 close_notify,日志里一堆"连接被重置"。这条记录不产生,连接池的健康检查会一直报异常。
3.3 delegated task 与 EventLoop 阻塞这件事
Netty 默认在 EventLoop 线程里驱动SslHandler。RSA 体系下这不是问题,因为 JDK 的SSLEngine会把耗时的密钥交换运算包装成 delegated task 扔出去。国密场景下如果换成了自己写的实现,SM2 的签名和验签都是纯 CPU 运算,单次在毫秒量级,高并发时会把 EventLoop 直接打满。
症状很有辨识度:连接能建起来,但握手完成耗时忽高忽低,压测时大量连接卡在握手阶段,业务侧表现为"请求发出去了但很久没响应",而 CPU 使用率看着也不算高——因为时间都花在少数几个 EventLoop 线程上了。
解决办法是把重活挪出去。SslHandler有接受Executor的构造器,把NEED_TASK派发的任务丢到业务线程池里执行;如果你封装的协议栈内部自己管理运算,那就要确保getDelegatedTask()返回的 task 是把运算真正转移出去的那种,而不是一个在当前线程直接跑的壳子。另外顺手把setHandshakeTimeoutMillis显式设置一下,默认值在手握网络抖动的环境里偏紧,超时后连接被关掉,日志里只留下一个含糊的握手超时。
4. 粘包、半包与 TLS record 边界:两层分帧别搞混
4.1 SslHandler 只保证字节流,不保证消息
这是我在排查"国密通信收到乱码"时发现最高频的误解。SslHandler解密之后交给下游的是一个字节流,它不保证一次channelRead拿到的就是一条完整业务消息,也不保证一条业务消息只对应一次channelRead。两条 JSON 粘在一起、或者一条 JSON 被切成两半,都是完全正常的现象。
正确的心智模型是:往上是业务消息边界,往下是 TLS record 边界,这两层之间没有对应关系。一条业务消息可能横跨多个 record,多条业务消息也可能被塞进同一个 record(取决于发送方的写聚合策略)。所以遇到"收到的数据不对",排查顺序应该是:先用日志把SslHandler解密后的原始字节 dump 出来,如果这批字节本身是对的,那问题百分百在分帧或反序列化,跟国密没有半点关系;如果 dump 出来的字节就是错的,那才轮到怀疑加解密。
4.2 分帧器的位置和参数怎么定
pipeline 的装配顺序建议固定成下面这样,出站方向 Netty 会自动逆序走,不用手动调整:
ch.pipeline() .addLast("ssl", gmSslContext.newHandler(ch.alloc())) .addLast("frameDecoder", new LengthFieldBasedFrameDecoder(64 * 1024, 0, 4, 0, 4)) .addLast("frameEncoder", new LengthFieldPrepender(4)) .addLast("business", new BusinessHandler());参数的含义要弄清:maxFrameLength是单帧的硬上限,给Integer.MAX_VALUE等于给内存打爆留了个后门,给得太小会在遇到大包时直接抛异常断连接。我们一般按业务最大单帧的两倍来设,留一点余量但不夸张。
这里有个细节值得单独说:不要把maxFrameLength和 TLS record 的大小搞混。record 明文的单条上限是 16KB 左右,但LengthFieldBasedFrameDecoder是基于累积的字节流工作的,它跨多条 record 拼一帧毫无问题,所以业务单帧完全可以超过 16KB。我见过有人为了"适配 record 大小"把分帧上限设成 16KB,结果上传稍大一点的报文就断连接,白折腾了半天。
4.3 握手阶段的半包同样要处理
不只是业务数据会半包,握手消息也会。客户端的 ClientHello 可能一次 read 拿不全;服务端的 Certificate 消息在证书链比较长的时候能到好几 KB,必然横跨多个 TCP 段。如果自定义 engine 在BUFFER_UNDERFLOW这条路上处理得不够严谨,就会在这些"大包"场景下偶发失败,而小证书的测试环境怎么都复现不出来——这是我最想提醒的一条:测试环境用的自签证书链很短,掩盖了很多分帧相关的实现缺陷。
实测手法上,我一般会在本地把 MTU 相关的影响放大来验证:写一个测试客户端,故意把 ClientHello 拆成几个小包分多次写出去,观察服务端能不能正确重组。能扛住这个测试,才敢说分帧实现是稳的。
5. 握手失败排查链路:没有 javax.net.debug 的时候靠什么
5.1 常见异常与原因的对照表
JDK 那套-Djavax.net.debug=ssl在这条路上是失效的,因为流量压根没经过 SunJSSE。所以得自己建一套定位方法。先给一张我整理的对照表,多数问题能在这张表里定位到方向:
| 现象 / 异常 | 常见根因 | 优先排查动作 |
|---|---|---|
no cipher suites in common | 两端套件集合无交集,或一端禁用了所需的密钥交换方式 | 分别打印两端 supported 与 enabled 列表做交集 |
Received fatal alert: handshake_failure | 证书链不完整、签名证书与加密证书用反、缺少中间 CA | 检查Certificate消息里的证书张数和顺序 |
bad record mac | 密钥或 IV 不一致、record 分帧实现有缺陷、字节被提前消费 | 先排除分帧,再核对密钥导出流程 |
BufferUnderflowException出现在 SslHandler 内部 | 自定义 engine 报告的bytesConsumed与实际不符 | 在 wrap/unwrap 入口打点记录消耗与产出字节数 |
| 握手成功但业务数据是乱码 | 密文被当成明文交给业务,或分帧缺失 | dump 解密后的原始字节,逐层比对 |
| 连接建立后迅速断开 | close_notify 处理错误,或空闲检测把正常心跳判成空闲 | 查看连接关闭时的 alert 及空闲检测位置 |
5.2 二分法定位:把原生服务端当成参照物
这套方法我用了很多次,非常有效。准备一个能正常工作的原生服务端(第一篇里跑通的那个),然后做两次对照测试:让你的 Java 客户端去连原生服务端——如果通了,说明客户端实现没问题,锅在服务端侧;如果连原生服务端也不通,那问题一定在客户端。同样的逻辑,用一个原生客户端去连你的 Java 服务端。两轮下来,问题范围立刻缩小一半。
在此基础上再补一层日志打点。我在自定义 engine 里固定加了几个位置的日志:beginHandshake调用的时刻、每次wrap/unwrap前后的HandshakeStatus、每次返回的consumed和produced字节数、以及异常抛出的位置。这套日志在正常业务下几乎不产生输出,一旦握手出问题就能直接看出卡在哪一步——是卡在等对端证书,还是卡在本地签名运算,还是状态机死活不往FINISHED走。
还有一招是解析抓包。TLS record 的头部是明文的:第一个字节是内容类型,接着是版本号,再往后两个字节是 record 长度。光是手工读这五个字节,就能判断出当前是握手记录还是应用数据记录、record 长度是否和实际收到的字节数吻合。遇到"数据被截断"类的怀疑时,这一步能快速证实或证伪。
5.3 两个真实案例的复盘
第一个案例是证书链顺序。测试环境一切正常,一上预发就握手失败。日志里只有一句笼统的握手失败,看不出细节。后来把服务端发出的证书逐张导出比对,发现预发环境用的是正式的证书链,中间 CA 那一张没有配进去,本地自签的那套是一张自签根直接签叶子,所以本地怎么测都没事。教训是:测试环境必须用和生产同构的证书链结构,自签单证书掩盖的问题比你想象的多。
第二个案例更阴险:压测时偶发bad record mac,单次请求怎么压都不复现,只有并发到一定量级才出现。查了两天,最后定位到是ByteBuf的引用计数问题——某个自定义 Handler 在处理完数据后提前做了release,导致偶发地把还在被 SslHandler 使用的缓冲区回收了,缓冲区内容被复用后解密自然失败。定位它的关键在于:把 leak detection 的级别调到最严格,同时在压测时打开详细的缓冲区生命周期日志。这类问题的正解不是"加个 try-catch 吞掉",而是把引用计数的所有权关系理清楚。
6. SM2 运算开销与长连接策略:把握手成本摊薄
6.1 SM2 和 RSA 的开销差异在哪
先说结论:SM2 的签名(私钥运算)比同等安全强度的 RSA 要快,但验签(公钥运算)明显更慢。这个特性直接决定了国密握手的成本结构跟 RSA 完全不同。RSA 场景下手握的大头在服务端签名,所以服务端是瓶颈;国密场景下双方都要做验签,客户端和服务端的压力都比想象中大。
一次完整的国密握手里,客户端至少要做这些非对称运算:验证服务端签名证书链上每一张证书的签名、验证握手过程中服务端的签名、可能还要做一次密钥交换的运算。服务端同样要签名若干次。短连接高频握手在国密场景下是纯粹的算力浪费——每条连接都要把这一整套重算一遍,而业务可能只传了几百字节。
如果你想知道具体慢到什么程度,别信任何人的数字,自己压一遍。写个简单的循环,把 SM2 的签名和验签各跑一千次取平均,在你自己目标机型的容器限制下测,这个数字才是决策依据。我见过同样的算法在不同 CPU 上有数倍的差距,硬件加速指令集的支持情况影响很大。
6.2 会话复用、连接池与长连接怎么选
最直接的优化就是减少握手次数。如果底层协议栈支持会话复用,务必打开,让复用连接跳过完整的证书验证流程。如果协议栈版本不支持,那就退而求其次:用长连接加连接池,把握手次数从"每请求一次"降到"每连接一次"。
连接池有几个参数需要认真定,不能全用默认值:
- 最大连接数:按服务端能承受的并发握手量来定,别只按业务并发定。
- 空闲回收时间:太长会占着资源不放手,太短则频繁重建连接,反而把握手次数拉高。
- 单连接最大请求数:这条最容易被忽略。一条 TLS 连接上跑了太久、太多请求,加密强度会随时间衰减(同一套密钥用得越久风险越高)。设一个上限,到量后优雅关闭重建,比无限期复用更稳妥。
心跳和空闲检测也要跟着调整。IdleStateHandler要放在SslHandler之后,让它在解密后的字节流上做判断;否则 TLS 内部的心跳记录会被当成"有业务数据",空闲检测形同虚设。
6.3 随机数、IV 和线程安全
这条偏底层,但真的会出事。SM2 签名依赖一个每次都要不同且不可预测的随机数,如果这个随机数质量不过关或者复用了,私钥存在被推导出来的风险。Java 里用SecureRandom就行,但要注意两个细节:一是某些运行环境的默认熵源在容器里可能很慢甚至阻塞,启动时预热一下能避免第一批握手特别慢;二是不要在多个线程里共享一个自己实现的随机数状态机,要么用标准SecureRandom的线程安全方法,要么每个线程一个实例。
SM4 在 CBC 模式下,每条 record 的 IV 必须不同。如果你是自己实现 record 层的,IV 要正确地从握手导出的密钥块里切出来,千万别图省事用一个固定的 IV,那等于把加密强度降了一大截。GCM 模式则是 nonce 唯一性的问题,同一个密钥下 nonce 重复的后果比 CBC 更严重。
还有个小坑:BC Provider 只需要注册一次,重复调用addProvider会在 Provider 列表里堆积重复项,某些版本下还会触发警告,写个静态初始化块加getProvider判空就够了。
7. 上线前容易被忽略的校验与监控项
7.1 主机名校验这件事,别丢
前面提过,自定义 engine 不会自动做域名匹配。标准SSLEngine在peerHost非空时会做 endpoint identification,而这条路径在你自己的实现里是断的。如果你没有显式补上,结果就是握手成功、数据通畅、身份未验证,一个纯静默的安全缺口。
我的做法是在握手完成的回调里手动取出对端证书,比对 SAN 里的 DNS 名称,同时显式校验证书的有效期和用途。用途校验尤其重要:双证书体系里签名证书和加密证书是分开的,如果拿到一张签名证书去做密钥交换相关的运算,虽然可能不报错,但违反了证书的用途约定,也存在被替换的风险。
7.2 值得采集的几个指标
上线之后,光看"服务是否存活"远远不够。国密通道有几个指标是必须采的,它们各自指向不同的问题:
| 指标 | 说明 | 异常时指向的问题 |
|---|---|---|
| 握手耗时 P50 / P99 | 从beginHandshake到FINISHED的耗时分布 | P99 抖说明 EventLoop 被阻塞或对端响应慢 |
| 握手失败率 | 单位时间内失败的握手次数占比 | 突增通常是证书过期或套件配置被改 |
| 加解密吞吐 | 单位时间处理的明文与密文字节数 | 下降说明运算成为瓶颈或线程池被打满 |
| 活跃连接数 | 当前保持的 TLS 连接数量 | 持续上涨且不回落说明连接泄漏 |
| 异常关闭比例 | 非 close_notify 关闭占全部关闭的比例 | 偏高说明存在超时、强制断连或实现缺陷 |
握手失败率这个指标特别值得单独告警,因为国密场景下的证书过期问题比 RSA 更容易被忽略——很多团队的证书巡检脚本只认标准算法签发的证书,国密证书到期了没有任何提醒。
7.3 灰度切换与回退时的一个细节
如果是在已有的国际算法通道上叠加国密能力,双栈并行期间一定要用配置开关控制,而且发布顺序有讲究:先让两端都具备处理两种算法的能力,再把客户端切到优先国密,最后才让服务端强制国密。顺序反了会出现一段时间的全面握手失败。
回退的时候有个容易踩的点:把开关切回去之后,连接池里那些已经建立的国密连接不会立刻断开,它们会继续按国密的规则传数据。如果此时另一端已经回退到国际算法,就会出现"配置改完了还在报国密相关的错"的现象。正确做法是切换开关的同时主动排空连接池,让旧连接优雅下线。
最后分享一个我自己用了很久的小习惯:在启动日志里打印一行"当前生效的套件、协议版本、证书别名和有效期",看起来是句废话,但每次环境切换、错误配置、证书过期,这一行日志都能让你在三十秒内确认状态,比翻半个小时的配置文件快得多。
<ds_safety>[用户未成年]否 [分类]其他 [判定]内容为GMSSL/Netty国密TLS技术博客,仅涉及密码学与工程实现,无政治或色情元素。 [规则]无</ds_safety>Safe