客户提需求说"这套系统要走国密 TLS",我第一反应是openssl敲两行命令的事,结果坐下来一动手才发现,国密自签名证书生成这条路上埋的坑比想象中多得多。普通 RSA 自签证书你可以一条命令生成、浏览器直接认、Nginx 直接挂,而国密证书涉及 SM2 曲线、SM2-with-SM3 签名算法、双证书体系、国密版 TLS 协议栈,还有客户端那一侧的国密浏览器插件——任何一环没对上,握手就直接失败,报错信息还特别含糊。
这篇内容我打算把国密自签名证书从生成到落地部署这一整条链路拆开讲。核心关键词就三个:国密、自签名证书、证书生成。适合谁看?如果你的项目里有"等保要求国密改造""内网系统必须用 SM2 证书""要和国密浏览器对接"这类需求,那基本就是给你写的。我会重点讲清楚三件事:证书到底怎么生成(含多域名 SAN 配置)、Nginx 和 Tomcat 两边怎么挂上去、以及客户端信任和联调里那些文档里不会写的坑。工具链方面我会把 GmSSL、OpenSSL、Java keytool 三条路都摆出来对比,你可以按自己环境的实际情况挑一条。
1. 先把国密证书的"特殊之处"搞明白再动手
很多人上手第一件事就是搜命令,结果搜到的命令跑出来是一张"看起来像证书"的东西,挂上去却不生效。根本原因在于,国密证书不是"换了算法的 RSA 证书",它在格式、结构、协议层都有额外约定。这几点不弄清楚,后面每一步都会返工。
1.1 国密证书和普通证书在编码层面的真实差异
从 X.509 结构上看,国密证书和普通证书的骨架是同一套——版本号、序列号、签发者、有效期、主体、公钥信息、扩展字段、签名值。差别藏在几个 OID 里:
| 项目 | 普通证书常见取值 | 国密证书取值 |
|---|---|---|
| 公钥算法 OID | 1.2.840.113549.1.1.1(RSA) | 1.2.156.10197.1.301(SM2) |
| 签名算法 OID | 1.2.840.113549.1.1.11(sha256WithRSA) | 1.2.156.10197.1.501(SM2-with-SM3) |
| 摘要算法 OID | 2.16.840.1.101.3.4.2.1(SHA-256) | 1.2.156.10197.1.401(SM3) |
| 曲线参数 OID | 1.2.840.10045.3.1.7(P-256) | 1.2.156.10197.1.301(sm2p256v1) |
这几个数字看着枯燥,但它们是排查问题的关键。客户端在校验证书时,会先读签名算法 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 "/C=CN/ST=Beijing/L=Haidian/O=DemoCompany/OU=DevTeam/CN=example.com" \ -addext "subjectAltName=DNS:example.com,DNS:www.example.com,DNS:api.example.com"关键在-sm3这个参数。不加它,openssl req会用默认的 SHA-256 做摘要,签出来的证书签名算法就变成SM2-with-SHA256而不是SM2-with-SM3,很多国密协议栈会拒绝这种组合。这个坑我见过至少三拨人踩过。
到了 OpenSSL 3.x,API 和 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 单干不行,必须拉上 BouncyCastle
Java 世界里生成 SM2 证书稍微绕弯。JDK 自带的 SunEC provider 只实现 NIST 系列曲线,sm2p256v1不在其中,所以你必须引入 BouncyCastle。命令形式大概是这样:
keytool -genkeypair \ -alias sm2sign \ -keyalg EC -groupname sm2p256v1 \ -keystore sm2.p12 -storetype PKCS12 \ -validity 3650 \ -dname "CN=example.com, OU=DevTeam, O=DemoCompany, L=Haidian, ST=Beijing, C=CN" \ -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.x | OpenSSL 1.1.1/3.x | keytool + BC |
|---|---|---|---|
| SM2/SM3 支持 | 完整,含 SM9 | 1.1.1 起支持 SM2 | 依赖 BC |
| 多域名 SAN 配置 | 较麻烦 | 非常方便(-addext) | 需要配置文件 |
| 私钥默认格式 | 加密 PKCS#8 | 未加密 SEC1 | PKCS12 容器 |
| 产出格式适配 | 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 "/C=CN/O=DemoCompany/OU=PKI/CN=Demo SM2 Root CA" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ -addext "keyUsage=critical,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导入一次信任库,后续换证书不用重新导入。在需要频繁轮换的内网环境里,这个模式省事很多。
证书链文件在部署时通常要拼成一个 PEM:
cat 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 2>&1 | 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_file |
cannot 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(证书处理)、bctls(TLS 协议实现)。把它们放进 Tomcat 的lib目录,然后在server.xml的 Connector 上指定 SSL 实现:
<Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol" SSLEnabled="true" scheme="https" secure="true" sslImplementationName="org.bouncycastle.jsse.provider.BouncyCastleJsseProvider" sslEnabledProtocols="GMSSLv1.1" keystoreFile="/etc/gm/sign.p12" keystoreType="PKCS12" keystorePass="changeit" truststoreFile="/etc/gm/trust.p12" truststoreType="PKCS12" truststorePass="changeit" />这里有个绕不开的准备工作:把 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 port="8443" protocol="org.apache.coyote.http11.Http11AprProtocol" SSLEnabled="true" scheme="https" secure="true" SSLCertificateFile="/etc/gm/sign-chain.crt" SSLCertificateKeyFile="/etc/gm/sign.key" SSLPassword="123456" 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 port="8080" protocol="HTTP/1.1" connectionTimeout="20000" proxyPort="443" />这样做的收益非常明显: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_SM3 | 0xE011 | 兼容性最好,多数实现都支持 |
| ECDHE_SM4_CBC_SM3 | 0xE013 | 支持前向安全 |
| ECC_SM4_GCM_SM3 | 0xE051 | 认证加密,性能更好 |
| ECDHE_SM4_GCM_SM3 | 0xE053 | 前向安全加认证加密 |
配置服务端时我一般会把前两个都写上,兼容性优先,等确认客户端能连再逐步收紧。
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 程序用的是自己的 cacerts,Node.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 里是SM2,Java 和 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 吃 PEM,Java 吃 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 那一处,风险面小很多。