
在一口气把 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)就能拿到一个标准的SSLEngineNetty 侧一行代码不用改。我们最初就是冲着这条路去的实测下来有两个门槛一是它的 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 认不出国密 OID2.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.debugssl在这条路上是失效的因为流量压根没经过 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_safetySafe