
客户提需求说这套系统要走国密 TLS我第一反应是openssl敲两行命令的事结果坐下来一动手才发现国密自签名证书生成这条路上埋的坑比想象中多得多。普通 RSA 自签证书你可以一条命令生成、浏览器直接认、Nginx 直接挂而国密证书涉及 SM2 曲线、SM2-with-SM3 签名算法、双证书体系、国密版 TLS 协议栈还有客户端那一侧的国密浏览器插件——任何一环没对上握手就直接失败报错信息还特别含糊。这篇内容我打算把国密自签名证书从生成到落地部署这一整条链路拆开讲。核心关键词就三个国密、自签名证书、证书生成。适合谁看如果你的项目里有等保要求国密改造内网系统必须用 SM2 证书要和国密浏览器对接这类需求那基本就是给你写的。我会重点讲清楚三件事证书到底怎么生成含多域名 SAN 配置、Nginx 和 Tomcat 两边怎么挂上去、以及客户端信任和联调里那些文档里不会写的坑。工具链方面我会把 GmSSL、OpenSSL、Java keytool 三条路都摆出来对比你可以按自己环境的实际情况挑一条。1. 先把国密证书的特殊之处搞明白再动手很多人上手第一件事就是搜命令结果搜到的命令跑出来是一张看起来像证书的东西挂上去却不生效。根本原因在于国密证书不是换了算法的 RSA 证书它在格式、结构、协议层都有额外约定。这几点不弄清楚后面每一步都会返工。1.1 国密证书和普通证书在编码层面的真实差异从 X.509 结构上看国密证书和普通证书的骨架是同一套——版本号、序列号、签发者、有效期、主体、公钥信息、扩展字段、签名值。差别藏在几个 OID 里项目普通证书常见取值国密证书取值公钥算法 OID1.2.840.113549.1.1.1RSA1.2.156.10197.1.301SM2签名算法 OID1.2.840.113549.1.1.11sha256WithRSA1.2.156.10197.1.501SM2-with-SM3摘要算法 OID2.16.840.1.101.3.4.2.1SHA-2561.2.156.10197.1.401SM3曲线参数 OID1.2.840.10045.3.1.7P-2561.2.156.10197.1.301sm2p256v1这几个数字看着枯燥但它们是排查问题的关键。客户端在校验证书时会先读签名算法 OID如果读到的是1.2.156.10197.1.501而它的密码模块里没有 SM3 实现就会直接判定不支持的签名算法。同理公钥算法 OID 不是 SM2国密协议栈就认为这张证书不能用。所以生成完之后第一件事永远是openssl x509 -in xxx.crt -noout -text看一眼 Signature Algorithm 和 Public Key Algorithm 两行别急着往服务器上挂。我自己就吃过一次亏用某个脚本生成的国密证书公钥其实是 P-256 曲线只是签名用了 SM3 摘要看着像国密实际上客户端一律拒绝。1.2 双证书体系签名证书和加密证书为什么必须拆开这是国密 TLS 和普通 TLS 最大的结构性区别。普通 TLS 里服务器一张证书、一对密钥既做身份认证签名又参与密钥交换。国密 TLS 规范要求服务器持有两张证书、两对密钥签名证书私钥只用于对握手消息签名密钥用法keyUsage通常是digitalSignature、nonRepudiation加密证书私钥用于解密客户端用服务器加密公钥加密的预主密钥密钥用法通常是keyEncipherment、dataEncipherment为什么要拆两个层面的原因。一是密码学上的密钥用途单一化——一把私钥只干一件事攻击面就小万一加密私钥泄露不影响历史握手的签名有效性二是合规层面密码模块管理要求密钥的生命周期、使用权限、销毁流程分开管理签名密钥和加密密钥的审批流程往往都不是同一个。这个设计直接带来一个部署上的后果你在 Nginx 或 Tomcat 上只配一对证书握手必定失败。国密协议栈在 ServerHello 之后会期待服务端发两张证书只发一张会被客户端判定为协议违规。这一点我在第一次配置时踩得最惨日志里只有一句handshake failure什么有效信息都没有。1.3 自签场景的边界哪些环境能认哪些环境根本不认自签名证书的本质是自己给自己背书没有第三方 CA 的信任锚。在国密场景下自签的适用范围比普通 HTTPS 还要窄一些得提前跟对接方确认清楚标准浏览器基本不认。Chrome、Firefox、Edge 的主流版本不实现 SM2 的 TLS 密码套件你挂上国密证书它们连 ClientHello 阶段就对不上报ERR_SSL_VERSION_OR_CIPHER_MISMATCH之类的错。必须用具备国密能力的浏览器或国密插件。需要客户端预置信任。自签证书要生效客户端必须把你的根证书或自签证书本身导入信任库。国密浏览器的信任库位置和系统信任库往往是两套得分别导。中间件有版本门槛。Java 侧如果只依赖 JDK 自带的 SunEC provider是拿不到 SM2 曲线的必须引入 BouncyCastle 之类的第三方 provider。不适合对公网开放的生产系统。自签证书的吊销、轮换、审计都靠人工公网业务还是走正式的 SM2 CA 签发更稳妥。把这三件事想明白后面选工具、写配置、排查故障都会顺很多。反过来说如果你上来就抄命令大概率会在证书生成了但挂不上和挂上了但连不通之间反复横跳。2. 工具链选型GmSSL、OpenSSL、JDK 三条路各有各的适用面生成国密证书的工具不止一种选错了工具会在后面每一个环节付出代价。我把三个主流选择摆开讲包括它们各自的能力边界你按自己的环境挑。2.1 GmSSL 3.x命令风格贴近 OpenSSL但扩展字段能力有限GmSSL 是国产开源密码工具箱对 SM2、SM3、SM4、SM9 的支持是最完整的命令行的组织方式也刻意模仿了 OpenSSL用过 openssl 的人上手很快。它的典型用法是这样# 生成 SM2 密钥对私钥加密保存同时导出公钥 gmssl sm2keygen -pass 123456 -out sign.key -pubout sign.pub # 用私钥自签一张证书 gmssl certgen \ -C CN -ST Beijing -L Haidian \ -O DemoCompany -OU DevTeam \ -CN example.com \ -days 3650 -serial 0x1001 \ -key sign.key -pass 123456 \ -out sign.crt注意-pass参数GmSSL 3.x 生成的私钥默认是加密的 PKCS#8 格式PEM 头是BEGIN ENCRYPTED PRIVATE KEY。这个设计有好有坏好处是私钥落盘时天然带保护坏处是后面 Nginx 加载时要额外提供密码文件忘了配就会报PEM_read_bio_PrivateKey failed而这条报错完全不会提示你是密码的问题。GmSSL 的短板在于扩展字段的灵活度。多域名 SAN、自定义扩展 OID 这些需求GmSSL 3.x 的certgen支持得不如 OpenSSL 顺手。我的实际做法是密钥用 GmSSL 生成因为有些合规环境明确要求密钥由国密工具产生证书本身用 OpenSSL 加载同一把私钥来签——两者对 SM2 私钥的编解码是互通的这条路我跑通过很多次。2.2 OpenSSL 1.1.1 和 3.x能力够用但版本差异是个隐形陷阱OpenSSL 从 1.1.1 开始内置了 SM2 曲线和 SM3 摘要这是最省事的路线。生成命令# OpenSSL 1.1.1直接从 SM2 曲线生成私钥 openssl ecparam -name SM2 -genkey -noout -out sign.key # 自签一张带 SAN 的多域名证书 openssl req -new -x509 -key sign.key -out sign.crt -days 3650 -sm3 \ -subj /CCN/STBeijing/LHaidian/ODemoCompany/OUDevTeam/CNexample.com \ -addext subjectAltNameDNS:example.com,DNS:www.example.com,DNS:api.example.com关键在-sm3这个参数。不加它openssl req会用默认的 SHA-256 做摘要签出来的证书签名算法就变成SM2-with-SHA256而不是SM2-with-SM3很多国密协议栈会拒绝这种组合。这个坑我见过至少三拨人踩过。到了 OpenSSL 3.xAPI 和 provider 架构大改命令也跟着变了# OpenSSL 3.x用 genpkey 生成 openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:SM2 -out sign.key3.x 里 SM2 相关的算法被挪进了 provider如果系统里同时装了多个 provider或者openssl.cnf里没有正确启用 default provider你会遇到曲线找不到或者算法不支持这类莫名奇妙的问题。判断方法很简单先跑一句openssl ecparam -list_curves | grep -i sm2能列出来说明当前配置可用列不出来就先把 provider 配置理顺别硬着头皮往下走。2.3 Java 体系keytool 单干不行必须拉上 BouncyCastleJava 世界里生成 SM2 证书稍微绕弯。JDK 自带的 SunEC provider 只实现 NIST 系列曲线sm2p256v1不在其中所以你必须引入 BouncyCastle。命令形式大概是这样keytool -genkeypair \ -alias sm2sign \ -keyalg EC -groupname sm2p256v1 \ -keystore sm2.p12 -storetype PKCS12 \ -validity 3650 \ -dname CNexample.com, OUDevTeam, ODemoCompany, LHaidian, STBeijing, CCN \ -providerclass org.bouncycastle.jce.provider.BouncyCastleProvider \ -providerpath ./bcprov-jdk18on-1.78.jar \ -storepass changeit -keypass changeit-groupname参数需要 JDK 10 及以上-providerclass和-providerpath是让 keytool 临时挂载 BC provider。这里还有个细节JDK 与 BC 版本必须匹配BC 的jdk18on系列对应 JDK 8 以上如果你的项目还在 JDK 8得挑 BC 的兼容包否则会报NoSuchAlgorithmException: sm2p256v1。三条路线的横向对比维度GmSSL 3.xOpenSSL 1.1.1/3.xkeytool BCSM2/SM3 支持完整含 SM91.1.1 起支持 SM2依赖 BC多域名 SAN 配置较麻烦非常方便-addext需要配置文件私钥默认格式加密 PKCS#8未加密 SEC1PKCS12 容器产出格式适配PEM 为主PEM/DER 灵活JKS/PKCS12适合场景合规要求国密工具通用生成与转换Java 服务端集成我的建议是主力用 OpenSSL 做证书签发把 GmSSL 当密钥生成器和校验工具Java 侧只在必须生成 keystore 时才用 keytool。这样组合起来的效率最高也最好排查问题。3. 从零生成一套能用的签名证书和加密证书工具选定之后进入实操。我以 OpenSSL 为主线因为它在扩展字段上最灵活生成的证书也最容易在 Nginx、Tomcat 两侧复用。3.1 两条密钥分开生成别图省事复用国密双证书的第一个动作是生成两条独立的 SM2 密钥对# 签名密钥 openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:SM2 -out sign.key # 加密密钥 openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:SM2 -out enc.key # 看一眼曲线参数对不对 openssl ec -in sign.key -noout -text 2/dev/null | head -5很多人第一反应是一张证书都用同一把密钥不是更省事这在国密场景下是明确不合规的。签名密钥和加密密钥必须物理隔离生成之后建议把权限收紧到 400并且尽量不要放在同一台机器上。如果你用 GmSSL 生成密钥会得到加密 PKCS#8 格式OpenSSL 也能读只是每次调用都要输密码脚本化的时候用-passin传openssl pkey -in gmssl_sign.key -passin pass:123456 -out sign_plain.key这一步的产物sign_plain.key是未加密的 PKCS#8方便后续脚本批量处理。注意未加密私钥落盘只是临时手段签完证书要么删掉要么用openssl pkey -aes256重新加密。我见过太多人为了省事把裸私钥长期放在工作目录里这在等保检查里是直接扣分项。3.2 手写扩展配置把 SAN 和密钥用法一次配到位多域名场景靠命令行参数堆-addext会越来越乱正经做法是写一个配置文件。下面这份sm2-sign.cnf可以直接抄[ req ] default_md sm3 distinguished_name dn prompt no [ dn ] C CN ST Beijing L Haidian O DemoCompany OU DevTeam CN example.com [ v3_sign ] basicConstraints critical, CA:FALSE keyUsage critical, digitalSignature, nonRepudiation extendedKeyUsage serverAuth, clientAuth subjectKeyIdentifier hash authorityKeyIdentifier keyid, issuer subjectAltName alt_names [ alt_names ] DNS.1 example.com DNS.2 www.example.com DNS.3 api.example.com DNS.4 *.internal.example.com IP.1 192.168.10.20几个字段值得单独说明。basicConstraints里的CA:FALSE是必须的——自签的叶子证书从身份上讲不是 CA把它标成 CA:TRUE 会让一部分严格的客户端直接拒绝。keyUsage加上critical标记是让校验方必须处理这个字段不能忽略。extendedKeyUsage里的serverAuth是服务端证书的标配如果这张证书还要做双向认证把clientAuth也加上。authorityKeyIdentifier在自签场景下指向自己有些实现会因此报签发者与主体相同的警告属于正常现象不影响使用。加密证书的配置需要单独改两处[ v3_enc ] basicConstraints critical, CA:FALSE keyUsage critical, keyEncipherment, dataEncipherment extendedKeyUsage serverAuth subjectAltName alt_names密钥用法从digitalSignature换成keyEncipherment, dataEncipherment这是双证书能够被协议栈正确配对的前提。如果两张证书的 keyUsage 写反了握手时会报证书用途不匹配。配置就绪后签证书# 签名证书 openssl req -new -x509 -key sign.key -out sign.crt -days 3650 \ -sm3 -config sm2-sign.cnf -extensions v3_sign # 加密证书 openssl req -new -x509 -key enc.key -out enc.crt -days 3650 \ -sm3 -config sm2-enc.cnf -extensions v3_enc3.3 生成后立刻自检别把问题留给部署阶段证书签完不要急着往服务器上拷先在本地把几个关键点验一遍# 1. 签名算法是不是 SM2-with-SM3 openssl x509 -in sign.crt -noout -text | grep -A1 Signature Algorithm # 2. 公钥算法和曲线 openssl x509 -in sign.crt -noout -text | grep -A3 Public Key Algorithm # 3. SAN 是否完整 openssl x509 -in sign.crt -noout -text | grep -A2 Subject Alternative Name # 4. 签名自洽性自签证书自己验自己 openssl verify -CAfile sign.crt sign.crt第 4 条命令在自签证书上返回OK说明签名值本身是自洽的。如果返回error 18: self signed certificate那多半是因为把证书当成了非自签场景来验加上-CAfile指向证书自身就能过。我特别想强调的是有效期检查。自签证书的notBefore默认是生成时刻如果服务器系统时间比签发时间早虚拟机快照恢复、容器时间没同步都会导致这种情况客户端会判定证书尚未生效。所以生成之后顺手跑一句openssl x509 -in sign.crt -noout -dates把输出里的起止时间和目标服务器的date对一下这是最容易被忽略、又最花时间排查的一类问题。3.4 想让链路更像真实 CA自建 SM2 根证书再签发严格来说自签的加密证书在很多国密协议栈里会被区别对待。如果你希望链路更规范可以自建一个 SM2 根 CA然后由它签发签名证书和加密证书# 生成根 CA 密钥 openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:SM2 -out ca.key # 生成自签的根 CA 证书有效期给长一点 openssl req -new -x509 -key ca.key -out ca.crt -days 7300 -sm3 \ -subj /CCN/ODemoCompany/OUPKI/CNDemo SM2 Root CA \ -addext basicConstraintscritical,CA:TRUE,pathlen:0 \ -addext keyUsagecritical,keyCertSign,cRLSign # 用根 CA 签发签名证书 openssl req -new -key sign.key -out sign.csr \ -sm3 -config sm2-sign.cnf openssl x509 -req -in sign.csr -CA ca.crt -CAkey ca.key \ -CAcreateserial -out sign.crt -days 3650 -sm3 \ -extfile sm2-sign.cnf -extensions v3_sign这样产出的是一条两级链ca.crt是信任锚sign.crt和enc.crt是叶子证书。对客户端的价值在于你只需要把ca.crt导入一次信任库后续换证书不用重新导入。在需要频繁轮换的内网环境里这个模式省事很多。证书链文件在部署时通常要拼成一个 PEMcat sign.crt ca.crt sign-chain.crt顺序不能反服务端证书必须放在最前面根证书跟在后面。4. Nginx 挂载国密证书标准版为什么不行该怎么补到了部署环节第一个现实问题是你手上装的那个标准 Nginx压根加载不了国密证书。这不是配置写错的问题是编译期能力缺失。4.1 标准 Nginx 加载 SM2 证书失败的根因Nginx 的 TLS 能力是从底层 OpenSSL 库借来的。即便你用的是 OpenSSL 1.1.1 或 3.x它能做的也只是用 SM2 密钥签一个 X.509 证书这一个动作。而国密 TLS 协议本身——也就是常说的 GM/T 0024 那套协议——在协议版本号、握手消息结构、密码套件取值上都和标准 TLS 不同标准 OpenSSL 根本没有实现这部分。具体表现就是你在nginx.conf里写上ssl_certificate指向 SM2 证书nginx -t可能能过因为文件读得进来但真正握手时客户端和服务端在 ClientHello 阶段就协商不出共同套件连接直接被 reset。日志里只有一行SSL_do_handshake() failed看不出所以然。解决办法只有一条换一个支持国密 TLS 的底层库并重新编译 Nginx。目前比较成熟的路线是用铜锁Tongsuo这类支持国密 TLS 的开源分支作为 Nginx 的加密库。4.2 重新编译把国密能力编进二进制里编译流程大致是这样前提是机器上已经有编译工具链# 1. 编译带国密 TLS 能力的加密库 cd /opt/src/tongsuo ./config enable-ntls --prefix/opt/tongsuo --openssldir/opt/tongsuo/ssl make -j4 make install # 2. 用这个库重新编译 Nginx cd /opt/src/nginx ./configure \ --prefix/opt/nginx \ --with-http_ssl_module \ --with-openssl/opt/src/tongsuo make -j4 make install这里的核心是enable-ntls它打开了国密 TLS有些文档里叫 NTLS有些叫 GMTLS指的是同一套协议。编译完之后验证一下/opt/nginx/sbin/nginx -V 21 | grep -o Tongsuo[^ ]*能打出加密库的版本信息说明链接成功。注意 Nginx 版本和加密库版本的兼容性新版本 Tongsuo 的 API 有变动太老的 Nginx 源码可能编不过反过来也一样。我一般会挑一个社区里被验证过的版本组合不追新。如果不想自己编译另一条路是用国内厂商打包好的 Nginx 国密发行版或者用 Tengine 的国密分支装完就能用。这种方式省时间代价是版本升级要跟着发行方走遇到问题也不太容易查到根因。4.3 双证书的挂载顺序与高频启动报错编译搞定之后配置文件的写法是这样的server { listen 443 ssl; server_name example.com; ssl_protocols NTLSv1.1; ssl_ciphers ECC_SM4_CBC_SM3:ECC_SM4_GCM_SM3; # 签名证书必须写在前面加密证书跟在后面 ssl_certificate /etc/gm/sign.crt; ssl_certificate_key /etc/gm/sign.key; ssl_certificate /etc/gm/enc.crt; ssl_certificate_key /etc/gm/enc.key; ssl_session_cache shared:GMSSL:10m; ssl_session_timeout 10m; location / { root /var/www/html; index index.html; } }几个必须注意的点ssl_certificate和ssl_certificate_key各出现两次这是国密补丁特有的语法签名对在前、加密对在后。顺序写反了会导致协议栈读到错误的证书类型握手直接失败。ssl_protocols里的NTLSv1.1是国密协议版本号普通 Nginx 不认识这个值会报invalid value NTLSv1.1。看到这个报错就说明你还在用标准版二进制。私钥有密码的话需要额外加一行ssl_password_file /etc/gm/ssl.pass;文件内容就是明文密码一行。有些人不愿意把密码落盘那就用openssl pkey把密钥转成无密码格式代价是私钥保护弱了一层自己权衡。常见的启动报错和处理方式报错信息大概率原因处理方式invalid value NTLSv1.1用的是标准 Nginx换成国密编译版本key values mismatch证书和私钥不是同一对用公钥指纹比对确认配对no cipher match服务端与客户端套件无交集检查 ssl_ciphers 取值PEM_read_bio_PrivateKey failed私钥有密码但没配 password_file补ssl_password_filecannot load certificate文件权限或路径错误检查属主和读权限还有个小经验Nginx 加载证书是启动时一次性读入内存的改完证书文件记得nginx -s reload不 reload 的话它用的还是老证书。这一点在排查我明明换了证书为什么还报老错误时特别关键。5. Tomcat 侧接入国密证书的三条路径Tomcat 的情况比 Nginx 复杂因为它纯粹跑在 Java 上所有 TLS 能力都来自 JVM 的 JSSE 层而标准 JDK 的 JSSE 对国密支持很有限。我列三条实际可行的路从重到轻排。5.1 纯 Java 路径BouncyCastle JSSE 提供国密 TLS 能力这条路的思路是引入 BouncyCastle 的 TLS 实现替换 JVM 默认实现。依赖上需要三个包bcprov密码算法、bcpkix证书处理、bctlsTLS 协议实现。把它们放进 Tomcat 的lib目录然后在server.xml的 Connector 上指定 SSL 实现Connector port8443 protocolorg.apache.coyote.http11.Http11NioProtocol SSLEnabledtrue schemehttps securetrue sslImplementationNameorg.bouncycastle.jsse.provider.BouncyCastleJsseProvider sslEnabledProtocolsGMSSLv1.1 keystoreFile/etc/gm/sign.p12 keystoreTypePKCS12 keystorePasschangeit truststoreFile/etc/gm/trust.p12 truststoreTypePKCS12 truststorePasschangeit /这里有个绕不开的准备工作把 PEM 格式的证书和私钥打成一个 PKCS#12 容器因为 Java 侧的 keystore 主要认这个格式。openssl pkcs12 -export \ -inkey sign.key -in sign.crt \ -name sm2sign -out sign.p12 \ -passout pass:changeit这条路的好处是纯软件、可移植、不依赖操作系统库代价是 BouncyCastle 的国密 TLS 实现和标准国密协议栈在细节上可能有差异某些客户端对接时会遇到兼容性问题。另外双证书的支持需要额外的配置BouncyCastle 的 JSSE provider 对签名证书和加密证书的配对逻辑要单独设置不如 Nginx 那种写两对就完事直观。5.2 APR 路径让 Tomcat 借用操作系统的国密 TLS 能力Tomcat 有一个可选的 APR 连接器走的是本地库tomcat-native调用 OpenSSL 的模式。如果把这个 OpenSSL 换成支持国密 TLS 的版本Tomcat 就能直接借用底层能力# 编译 tomcat-native 时链接国密版加密库 cd tomcat-native-2.x-src/native ./configure --with-apr/usr/local/apr \ --with-ssl/opt/tongsuo \ --with-java-home$JAVA_HOME make make install配置上Connector port8443 protocolorg.apache.coyote.http11.Http11AprProtocol SSLEnabledtrue schemehttps securetrue SSLCertificateFile/etc/gm/sign-chain.crt SSLCertificateKeyFile/etc/gm/sign.key SSLPassword123456 SSLCACertificateFile/etc/gm/ca.crt /这条路的优点是性能和兼容性都更接近原生 TLS缺点是配置链路长APR、tomcat-native、加密库三者的版本要能编到一起中间任何一环版本不匹配都会卡住。我在一个老项目上为了编通这个组合花了大半天最后发现是 tomcat-native 的版本太旧不认识新版加密库的 API。5.3 最省事的方案让 Nginx 做国密终结Tomcat 只跑 HTTP如果架构上允许我会强烈建议把国密 TLS 的复杂度全部集中在 Nginx 这一层Tomcat 只监听 HTTP 走内网location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; }Connector port8080 protocolHTTP/1.1 connectionTimeout20000 proxyPort443 /这样做的收益非常明显Tomcat 侧完全不用碰国密相关的任何依赖升级、迁移、扩容都不受影响证书轮换只需要动 Nginx 一处出了握手问题排查范围也只限定在 Nginx 和客户端之间。代价是 Tomcat 拿不到客户端的 TLS 证书信息如果业务需要做双向认证并提取客户端证书做鉴权就得靠 Nginx 通过请求头把证书信息透传过去。这是唯一需要权衡的地方。6. 客户端联调国密浏览器的信任配置和排查思路服务端配好只是成功了一半。国密自签证书最容易卡住的其实是客户端这一侧因为标准浏览器根本不是你的目标客户端。6.1 标准浏览器遇到国密服务端的真实表现用 Chrome 访问一个只开国密套件的 443 端口你看到的通常是这几种结果之一ERR_SSL_VERSION_OR_CIPHER_MISMATCH、ERR_CONNECTION_RESET、或者干脆页面一直转圈直到超时。这些现象不是配置错误而是在 ClientHello 阶段就没谈拢——浏览器发过去的密码套件列表里全是 TLS_AES_128_GCM_SHA256 这类服务端一个都不支持。判断方法很直接用抓包工具看一眼 ClientHello 里的 Cipher Suites 字段如果里面没有0xE0开头的套件说明这个客户端压根没国密能力换客户端如果有0xE0开头的套件但服务端还是拒绝那问题在服务端的套件配置或者证书链上国密套件的常见取值可以参考这张表不同实现略有差异以实际协议栈为准套件名称常见取值特点ECC_SM4_CBC_SM30xE011兼容性最好多数实现都支持ECDHE_SM4_CBC_SM30xE013支持前向安全ECC_SM4_GCM_SM30xE051认证加密性能更好ECDHE_SM4_GCM_SM30xE053前向安全加认证加密配置服务端时我一般会把前两个都写上兼容性优先等确认客户端能连再逐步收紧。6.2 把自签证书导入客户端信任库国密浏览器或国密插件的信任库通常是独立于操作系统的这一点和普通浏览器不太一样很多人以为在 Windows 的受信任的根证书颁发机构里导一遍就完事了结果插件照样报证书不可信。操作顺序一般是先在操作系统的证书管理器里导入ca.crt或者直接导入sign.crt自签场景打开国密浏览器的插件配置页找到证书信任管理之类的入口再做一次导入重启浏览器进程让插件重新加载信任库导入时有个细节要留意导入的必须是完整的证书链。如果你用的是自建根 CA 签发的叶子证书只导叶子证书而不导根证书浏览器会报签发者不受信任。有些管理界面只支持单个文件导入那就把根证书单独导一次。Linux 环境下如果是命令行工具做客户端路径会不一样# Debian/Ubuntu 系 sudo cp ca.crt /usr/local/share/ca-certificates/demo-sm2-ca.crt sudo update-ca-certificates # RHEL/CentOS 系 sudo cp ca.crt /etc/pki/ca-trust/source/anchors/ sudo update-ca-trust不过要提醒一句系统级的信任库更新只影响用系统信任库的程序。像 Java 程序用的是自己的 cacertsNode.js 用的是内置 bundle都得单独处理。Java 侧导入keytool -importcert -alias sm2demo \ -file ca.crt \ -keystore $JAVA_HOME/lib/security/cacerts \ -storepass changeit \ -noprompt6.3 握手失败的排查清单真到了连不通的时候按这个顺序往下查基本能定位到问题第一步确认服务端到底在听什么。用ss -lntp | grep 443看端口是不是真的在监听再看 Nginx 的错误日志有没有加载证书失败。第二步用国密工具做本地握手测试。不要直接开浏览器先用命令行工具压一遍gmssl s_client -connect 127.0.0.1:443 -ntls -gmssl -showcerts-showcerts会把服务端发回来的证书链全部打出来。如果只看到一张证书而你的配置里明明写了两对那说明双证书挂载没生效问题在配置层面。第三步抓包看协商过程。抓ClientHello和ServerHello重点看两端各自支持的套件列表有没有交集。如果服务端返回的是handshake_failure告警基本可以确定是套件或证书的问题。第四步检查时间。服务端和客户端的系统时间差如果超过证书有效期范围会直接判定证书无效。容器环境、虚拟机快照恢复之后特别容易出现这种问题一条date命令就能排除。第五步看 SAN。如果你用域名访问而证书里只有 IP 的 SAN或者反过来的情况客户端会报名称不匹配。用openssl x509 -in sign.crt -noout -text | grep -A3 Alternative核对一遍访问用的地址是否在列表里。这五步走完还没解决的问题我遇到过的只有一次最后发现是服务端装了不止一个 Nginx配置改的那份和实际生效的那份不是同一个。所以第六步可以顺手加一条确认nginx -V里的配置文件路径和你在改的文件路径一致。7. 几个反复踩到的坑和绕过去的办法前面讲的是完整流程这一节把那些文档里不会提、但实际会卡住你的细节单独拎出来。7.1 曲线名称的写法差异会直接导致加载失败同一个 SM2 曲线在不同工具里叫法不一样OpenSSL 里是SM2Java 和 BouncyCastle 里是sm2p256v1有些配置系统里还有SM2P256V1、sm2p256r1之类的变体。写配置文件的时候名字必须和当前工具认识的一致差一个字母就是unknown curve或者NoSuchAlgorithmException。排查这类问题有个笨办法但很有效直接用工具自己的列举命令确认关键词。openssl ecparam -list_curves | grep -i sm2输出什么就照着写什么不要凭记忆。7.2 密钥格式在三套体系之间来回转换从生成到部署一把 SM2 私钥可能要经历 SEC1、PKCS#8、PKCS#12 三种形态# SEC1 - PKCS#8加密 openssl pkcs8 -topk8 -in sign.key -out sign_pkcs8.key -v2 aes-256-cbc # PKCS#8 - 传统 EC 格式SEC1 openssl ec -in sign_pkcs8.key -out sign_sec1.key # PEM 证书 私钥 - PKCS#12 openssl pkcs12 -export -inkey sign.key -in sign.crt -out sign.p12 # PKCS#12 - PEM openssl pkcs12 -in sign.p12 -nodes -out sign_all.pem关键认知是Nginx 吃 PEMJava 吃 PKCS#12/JKS。每换一次部署目标就要做一次格式转换。转换过程中最容易出问题的是密码参数——-passin、-passout、-passout pass:xxx这几个写法在不同版本的 OpenSSL 里行为不完全一致脚本里最好显式把输入输出密码都写全别依赖交互式提示。7.3 CA:TRUE 造成的隐性拒绝最容易被忽略前面提过一次这里再说重一点。用脚本批量生成自签证书时很多模板默认会带上basicConstraints CA:TRUE因为创作者觉得自己签自己不就是 CA 吗。逻辑上说得通但实际影响是一部分客户端在校验服务端证书时会检查CA:FALSE发现是 CA 证书就直接拒绝连具体原因都不给。判断当前证书是哪种看这一行openssl x509 -in sign.crt -noout -text | grep -A1 Basic Constraints输出CA:FALSE就没问题CA:TRUE就需要重新签发。这个坑的特点是不报错、只失败排查起来特别耗时间所以我在每个项目的部署检查清单里都会放这一条。7.4 中文 DN 在某些实现上会被拒国密证书的主题信息里如果要写中文单位名编码方式必须用 UTF8String。但有些生成工具会用 PrintableString 或者 IA5String 来编码导致部分严格的解析器直接报错。我的处理方式很简单DN 里的所有字段都用英文中文信息放到证书之外的业务系统里。这样做既避开了编码兼容问题也省去了不同系统之间字符集来回转换的麻烦。证书的 DN 本身不承载业务语义用英文没有任何损失。7.5 SAN 里加通配符要留意匹配范围多域名证书里想省事很多人会直接写*.example.com。这个通配符只能匹配一级子域名——www.example.com能匹配上api.v2.example.com就匹配不上。如果你有跨多级的域名需求必须把*.v2.example.com单独列一条[ alt_names ] DNS.1 example.com DNS.2 *.example.com DNS.3 *.internal.example.com DNS.4 api.v2.example.com另外通配符对 IP 地址无效IP 必须单独写IP.1 x.x.x.x。这两条我见过太多人在联调阶段才发现那时候证书已经发出去几十份了。7.6 有效期别贪长自签证书尤其要注意轮换自签证书因为不用走 CA 流程很多人会直接设成 10 年、20 年图省事。但有几个现实约束一是不少客户端对有效期过长的证书有告警甚至拒绝策略二是密钥长期不轮换本身就是风险三是一旦私钥泄露长有效期的证书意味着更长的暴露窗口。我的习惯是给自签证书设 1 到 2 年然后在部署脚本里加一个到期检查任务提前 30 天告警。检查命令很简单openssl x509 -in sign.crt -noout -checkend 2592000这条命令检查证书在 30 天内是否会过期返回非零就触发告警。加到运维脚本里比人工记日期靠谱得多。最后再分享一个我在实际项目里用得比较顺的组合密钥用 GmSSL 生成以满足合规要求证书用 OpenSSL 配合配置文件签发以便灵活控制扩展字段Nginx 用国密编译版做 TLS 终结Tomcat 退到内网只跑 HTTP。这套组合的好处是每一层职责清晰出问题的时候只需要在单点上排查不用在 Java provider、本地库、协议栈之间来回猜。证书轮换的时候也只需要动 Nginx 那一处风险面小很多。