SM2、SM3、SM4:国密三件套各管哪一段,以及五个只有跑起来才发现的坑
提示词里出现「支持国密」四个字的时候,多数人的第一反应是「把 TLS 换个套件」。真去改代码才发现不是——国密不是一个算法,是一套分工:一把量数据的尺子、一把锁、一个印章,三个东西各管一段,谁也替不了谁。
我第一次撞上它,是给一个 MQTT broker 加 TLCP 支持。配置文件里原本只有一对 certfile / keyfile,TLCP 要求两对。那一刻我才明白,双证书不是「多填两个参数」,是数据模型从一对变成了两对。
下面所有命令和输出都在本机真跑过(Intel i7-14650HX,OpenSSL 3.5.7),不是抄的测试向量。跑的过程中记下五个坑,散在第三节到第五节。
一、先把分工摆清楚
三件套里只有 SM4 是「加密」,另外两个都不是。这一句如果一开始就搞错,后面全歪。
对照着 TLS 世界看,这三格分别是 SHA-256、AES、RSA/ECDSA。所以「支持国密」从来不是换一个函数调用,是三处一起换,而且换完之后握手协议(TLCP)也跟着变了。
二、SM3:长度和 SHA-256 一样,内部不是一个思路
SM3 见 GB/T 32905,输出 256 bit、分组 512 bit、64 轮。这三个数字和 SHA-256 完全一致——所以很多人以为它是「中国的 SHA-256」。粗看像,细看差得不少。
关键差别就两个,都在「扩散」上:
- 消息扩展产出两套字。SHA-256 扩展出 64 个字就完事;SM3 扩展出
W[0..67],再额外派生一套W'[j] = W[j] ^ W[j+4]。轮函数里两个都用(W'j进TT1、Wj进TT2),等于每轮吃两份消息,扩散面更大。 - 多了
P0/P1两个置换。扩展新字时不是简单异或,先过P1把比特搅一遍;压缩轮里状态更新再过P0。SHA-256 也有Σ/σ两族函数,但 SM3 把「扩展用 P1、更新用 P0」写得更直白,实现时抄错一个移位量结果就完全不同。
固定参数(写实现时要抄对):
| 项 | 值 |
|---|---|
| IV | 7380166f 4914b2b9 172442d7 da8a0600 a96f30bc 163138aa e38dee4d b0fb0e4e |
| Tj(0≤j≤15) | 79cc4519 |
| Tj(16≤j≤63) | 7a879d8a |
| FF / GG(j<16) | X^Y^Z |
| FF(j≥16) | (X&Y) ∨ (X&Z) ∨ (Y&Z) |
| GG(j≥16) | (X&Y) ∨ (~X&Z) |
跑一遍,跟公开测试向量对得上:
1 | $ printf 'abc' > sm3abc.txt |
空串也有摘要,这不是 bug——摘要算法对任何输入都有输出,包括长度为零的输入。这也是它跟加密最根本的区别:加密要能还原,所以必须有密钥、必须有逆运算;摘要只需要能比对,故意把信息量扔掉。
顺手验一下 HMAC-SM3 的雪崩效应(改一个字符、换一个密钥,结果全变):
1 | $ printf 'GET /balance HTTP/1.1' > req.txt |
这里有一个迁移时才想起来的便宜:SM3 摘要 32 字节,SHA-256 也是 32 字节。数据库里存摘要的字段、协议里放摘要的位置,宽度都不用改。这是三件套里唯一能「原地替换」的一处——但也仅限宽度,语义上的差异(比如某些规范要求 ZA 参与、某些要求特定前缀)还是得逐条对。
三、SM4:加解密是同一段电路
SM4 见 GB/T 32907,分组 128 bit、密钥 128 bit、32 轮。它是个广义 Feistel 结构:把 128 bit 拆成 4 个 32 bit 字 X0 X1 X2 X3,每轮吐出一个新字、挤掉最老的。
轮函数一行公式:
1 | X4 = X0 ^ T(X1 ^ X2 ^ X3 ^ rk) |
密钥扩展用的是同一套骨架,只是线性层换成 L'(B) = B ^ (B<<<13) ^ (B<<<23),再加 FK 和每轮的 CK:
| 项 | 值 |
|---|---|
| FK | a3b1bac6 56aa3350 677d9197 b27022dc |
| CK 生成 | ck[i][j] = (4i+j)*7 mod 256,四字节拼成 32 bit 字 |
| L | 循环左移 2 / 10 / 18 / 24 |
| L' | 循环左移 13 / 23 |
最有意思的一点:解密不需要另写一套。 轮函数是异或加可逆线性变换,把 32 个轮密钥倒序喂进去,同一段代码就是解密。硬件上这意味着加密和解密共用一块电路——面积省一半,这在智能卡、SE 芯片这种地方是真金白银。
用标准测试向量验一下(密钥和明文都是 0123456789abcdeffedcba9876543210,单分组、无填充):
1 | $ printf '0123456789abcdeffedcba9876543210' | xxd -r -p > pt.bin |
再看 CBC 模式下长度的变化——75 字节明文,输出 80 字节:
1 | $ printf 'SM4 is a 128-bit block cipher, used in TLCP and Chinese national standards.' > msg.txt |
75 向上取整到 16 的倍数是 80,多出来的 5 字节是 PKCS#7 填充(差 5 个字节就填 05 05 05 05 05)。这 5 字节同时是长度泄漏的源头,也是为什么真实协议里必须配认证——见坑二。
坑一:SM4 软实现比 AES 慢,而且差距不是一点点
同一台机器、同一个 OpenSSL、只换 -evp 参数,各跑 3 秒:
1 | $ for alg in sm4-cbc aes-128-cbc sm3 sha256; do |
(最后一列是 16 KB 块下的吞吐,单位 bytes/s。)
AES 快 3.5 倍,原因很土:AES-NI 是 CPU 指令。AESENC 一条指令干一轮,SM4 在 x86 上没有对应指令,只能走查表和向量化软件实现。这不是「SM4 设计得差」,是生态成熟度差异——ARMv8.4 之后有 SM4 指令,部分国产 CPU 也有,但你在自己的笔记本上测不到。
同时注意 SM3 和 SHA-256 这一组:SM3 反而快一点。上一轮我跑 2 秒的时候 SHA-256 是 143 MB/s、SM3 187;这一轮 3 秒 SHA-256 变成 261、SM3 172。波动这么大,说明这种 openssl speed 输出只能看量级,不能拿来下结论。 我第一次看到「SM3 比 SHA-256 快」时以为测错了,重跑两次才确认那是噪声——这本身也是个教训:openssl speed 跑一次的结果,别写进 PPT。
坑二:CBC 只保密,不保真
SM4-CBC 能防偷看,不能防篡改。改掉密文里任意一个 bit,解密出来的明文对应块会变成随机字节,但你发现不了——直到业务层拿它去查库、去反序列化,报一个跟加密毫无关系的错。
TLCP 里的标准职业做法是「先加密、再整体认证」:ECC-SM2-SM4-CBC-SM3 这个套件名拆开看就是——密钥交换 SM2、对称加密 SM4-CBC、完整性 HMAC-SM3。SM4 从不单独上岗,这也是第一节那张图里 SM3 和 SM4 必须并排出现的原因。
四、SM2:一条曲线上挂了三个功能
SM2 严格说不是「一个算法」,是一个基于椭圆曲线的算法套件(GB/T 32918 系列):数字签名、公钥加密、密钥交换,三份文档共享同一条曲线。
曲线参数如下(256 bit 素域):
| 参数 | 值 |
|---|---|
| p | FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFF |
| a | FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFC |
| b | 28E9FA9E 9D9F5E34 4D5A9E4B CF6509A7 F39789F5 15AB8F92 DDBCBD41 4D940E93 |
| n | FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF 7203DF6B 21C6052B 53BBF409 39D54123 |
| Gx | 32C4AE2C 1F198119 5F990446 6A39C994 8FE30BBF F2660BE1 715A4589 334C74C7 |
| Gy | BC3736A2 F4F6779C 59BDCEE3 6B692153 D0A9877C C62A4740 02DF32E5 2139F0A0 |
签名不是「对消息直接做椭圆曲线运算」,而是先用 SM3 把消息和上下文压成一个整数 e,再对这个整数签名:
1 | ZA = SM3(ENTL || ID || a || b || Gx || Gy || Px || Py) # 把用户身份绑进来 |
生成密钥、签名、验签:
1 | $ openssl ecparam -genkey -name SM2 -out sm2key.pem |
改一个数字再验:
1 | $ printf 'transfer 900 CNY to account 6222' > bad.txt |
注意命令里那个 -digest sm3。它不是可选项——SM2 签名把摘要算法写死在流程里了,换个摘要等于换个签名方案。
坑三:SM2 签名长度不是固定的 64 字节
直觉上 256 bit 的曲线,r 和 s 各 32 字节,签名应该正好 64 字节。实测连签 12 次:
1 | $ for i in $(seq 1 12); do |
70 / 71 / 72 三种长度都出现了(12 次里 72 有 5 个、70 有 5 个、71 有 2 个)。因为 OpenSSL 输出的是 DER 编码的 ASN.1 SEQUENCE:r、s 是大整数,最高位为 1 时要补一个 00、前导零要省略,编码长度就跟着抖。开头 30 45 02 21 就是「SEQUENCE、长度 0x45、INTEGER、长度 0x21」的解读。
坑在哪:任何按固定 64 字节解析签名的地方都会崩。定长数据库列、定长协议字段、拿 memcmp 比签名的代码,全都要按「读长度前缀」来做。不少库的 SM2_sign 直接吐 r||s(64 字节定长),跟 OpenSSL 的 DER 格式互不兼容——这两个格式之间的转换,是我见过最多的联调 bug 来源。
坑四:别信「SM2 只能加密 32 字节」
网上到处说 SM2 加密有长度上限、32 字节封顶。实测并不是:
1 | $ printf '0123456789abcdef0123456789abcdef' > keymat.bin # 32 字节 |
1000 字节也能加能解,密文比明文多 111 字节。SM2 加密流程里有 KDF(同样基于 SM3),本质是「椭圆曲线算出共享点 + 用 KDF 拉密钥流做异或」,所以长度没有硬上限。多出来的开销是 C1(一个未压缩椭圆曲线点,约 65 字节)+ C3(32 字节 SM3 摘要)+ 长度编码。
那为什么真实系统里还是「SM2 包 SM4 密钥」?因为慢。一次 SM2 加密要做椭圆曲线点乘,微秒到毫秒级;SM4 是 85 MB/s。用 SM2 加密 10 MB 数据,等于让卡车去送一封信。混合加密才是正解:
1 | 随机生成 16 字节 SM4 密钥 K |
TLCP 握手做的就是这个,只不过 K 是通过密钥交换协商出来的,不走「加密传输」。
坑五(规格层,不是实测):签名把用户 ID 焊死在流程里
ZA 里那个 ID 默认是字符串 1234567812345678(GM/T 0003.2 规定)。它的作用是把签名绑定到具体身份,防止签名被挪用到另一个上下文。
后果是:签名方和验签方的 ID 不一致,验签必然失败,而且报错通常只有「验证失败」四个字,看不出是 ID 不匹配。更麻烦的是,SM2 签名不能丢给通用 ECDSA 验签器——ECDSA 直接对 e 验,SM2 验的是带 ZA 的 e,且 s 的公式里还有 (1+d)^-1,数学形式就不同。
这一条我没有在本机用 CLI 复现(openssl pkeyutl 不给改 ID 的入口,要写 C 或 Python 调 EVP_PKEY_CTX_set1_id),所以标成规格结论而非实测结论。工程上记住结论就够了:跨系统对接 SM2 签名,先对 ID。
五、合起来:TLCP 为什么非要两对密钥
到这一步,三件套各自能跑了。真正让它们在工程里「合体」的是 TLCP(GB/T 38636-2020)。
TLCP 最反直觉的设计是双证书:握手时服务端要出示两张证书,一张用于签名(证明「我是我」),一张用于加密(用于密钥传输)。配置上就是两对文件。
为什么要拆两把钥匙?合规上的说法是密钥用途分离:签名密钥是法律意义上的身份凭证,必须长期保存、尽量离线、泄露了要吊销重签;加密密钥用于每次会话,可以被托管、可以恢复、泄露了换一把就好。两件事的风险模型完全不同,用同一把钥匙就是把最严的要求拖累最松的场景。
落到代码上,麻烦才真正开始。一套 TLS 栈的配置模型通常长这样:
1 | /* 一对证书,天经地义 */ |
TLCP 要的是再插一对:enc_certfile 和 enc_keyfile。
这不是「多两个配置项」那么简单。上游 OpenSSL 的 SSL_CTX 里根本没有「第二对证书」的位置——铜锁(原 BabaSSL)之所以要打 NTLS 扩展,就是因为原生 OpenSSL 的上下文结构表达不了「签名 + 加密」两套凭据,必须新增 SSL_CTX_use_enc_certificate / SSL_CTX_use_enc_PrivateKey 这类接口。我给 mosquitto 加 TLCP 支持时,改动里最不起眼但最关键的一条就是:配置文件解析器要把「一对」重构成「两对」,一个 listener 从 2 个字段变成 4 个,libmosquitto 的选项枚举也要跟着多三格。
这是那种「看文档永远想不到、写代码五分钟就撞上」的坑。文档只会说「TLCP 支持双证书」,只有当你真的去填 SSL_CTX 的时候,才发现问题不在协议、在你的数据结构。
六、一张表收尾
| 算法 | 标准 | 类型 | 关键尺寸 | 本机实测 | 典型用途 | 最容易踩的坑 |
|---|---|---|---|---|---|---|
| SM3 | GB/T 32905 | 摘要 | 输出 256 bit,分组 512 bit,64 轮 | SM3("abc") = 66c7f0f4…;16 KB 块 ≈ 172 MB/s |
完整性校验、HMAC、签名前置 | 当它是「加密」;以为换名即兼容 |
| SM4 | GB/T 32907 | 分组加密 | 分组 128 bit,密钥 128 bit,32 轮 | 测试向量 681edf34…;CBC ≈ 85 MB/s(AES 的 1/3.5) |
对称加密、TLCP 记录层 | CBC 不认证必须补 HMAC-SM3;软实现慢 |
| SM2 | GB/T 32918 | 椭圆曲线套件 | 256 bit 素域;私钥 32 B | 签名 DER 70~72 B;1000 B 明文 → 1111 B 密文 | 签名、公钥加密、密钥交换 | DER 与定长 r||s 互不兼容;ZA 里的 ID 必须一致 |
五个坑按「层级」重排一下,从认知到架构:
| 层级 | 坑 | 表现 | 修法 |
|---|---|---|---|
| 认知 | 「SM2 只能加密 32 字节」 | 该用混合加密的地方不敢用,或反过来拿 SM2 硬加大数据 | 它是 KDF 流式加密,长度无硬上限;但慢,只用来包密钥 |
| 编码 | SM2 签名 DER 长度浮动 | 定长解析崩、memcmp 假失败 |
按长度前缀读;或两端统一约定定长格式 |
| 参数 | ZA 里的用户 ID 不一致 |
验签失败且无信息量 | 对接前先确认 ID,写进接口文档 |
| 协议 | SM4-CBC 无认证 | 篡改检测不到,错误在下游爆 | 配 HMAC-SM3,或用认证加密模式 |
| 性能 | SM4 软实现只有 AES 的三分之一 | 替换后 CPU 飙高、吞吐掉 | 认硬件指令能力,别在 x86 上按 AES 的量级做容量规划 |
| 架构 | 一对证书装不下 TLCP | 配置模型表达不了双证书 | 上下文结构从 1 对改成 2 对,接口跟着加 |
本质一句话:SM3 是尺子,SM4 是锁,SM2 是印章加信封。国密的复杂度从来不在算法本身——三个算法都是公开设计,几页纸能写完——而在它把「证明身份」和「传输密钥」拆成了两把钥匙,而绝大多数现成的 TLS 栈,只会配一把。
最后补一句给准备动手的人:这三个算法,OpenSSL 从 1.1.1 起就内置了(SM2 要到 3.0 才顺手)。也就是说,验算法、跑测试向量、写 demo,你一行 C 都不用写。真正需要改代码的地方,永远在「协议怎么用它们」那一层——这也是为什么上面那些坑,全都不在算法里。

