提示词里出现「支持国密」四个字的时候,多数人的第一反应是「把 TLS 换个套件」。真去改代码才发现不是——国密不是一个算法,是一套分工:一把量数据的尺子、一把锁、一个印章,三个东西各管一段,谁也替不了谁。

我第一次撞上它,是给一个 MQTT broker 加 TLCP 支持。配置文件里原本只有一对 certfile / keyfile,TLCP 要求两对。那一刻我才明白,双证书不是「多填两个参数」,是数据模型从一对变成了两对。

下面所有命令和输出都在本机真跑过(Intel i7-14650HX,OpenSSL 3.5.7),不是抄的测试向量。跑的过程中记下五个坑,散在第三节到第五节。

一、先把分工摆清楚

三件套里只有 SM4 是「加密」,另外两个都不是。这一句如果一开始就搞错,后面全歪。

角色 输入 算法 输出 尺子 SM3 任意长度明文 M SM3 压缩 32 字节摘要 h 不可逆 锁 SM4 明文 M 加密钥 K SM4 分组迭代 密文 C 可逆 印章 SM2 摘要 h 或 密钥 K 私钥签 / 公钥加 SM2 椭圆曲线 签名 70~72B 或 密钥的密文

对照着 TLS 世界看,这三格分别是 SHA-256、AES、RSA/ECDSA。所以「支持国密」从来不是换一个函数调用,是三处一起换,而且换完之后握手协议(TLCP)也跟着变了。

二、SM3:长度和 SHA-256 一样,内部不是一个思路

SM3 见 GB/T 32905,输出 256 bit、分组 512 bit、64 轮。这三个数字和 SHA-256 完全一致——所以很多人以为它是「中国的 SHA-256」。粗看像,细看差得不少。

512 bit 分组 B0 … B15 消息扩展 Wj = P1(四项异或) + 两次移位 共 68 字,再派生 64 字 W[0..67] W'[0..63] W'j = Wj ^ Wj+4 64 轮压缩 · A B C D E F G H SS1 = ((A<<<12) + E + (Tj<<<j)) <<< 7 SS2 = SS1 ^ (A<<<12) TT1 = FF(A,B,C) + D + SS2 + W'j TT2 = GG(E,F,G) + H + SS1 + Wj P0 与 P1 置换 P0(X)=X^(X<<<9)^(X<<<17) P1(X)=X^(X<<<15)^(X<<<23) 与 IV 异或 得本块摘要

关键差别就两个,都在「扩散」上:

  1. 消息扩展产出两套字。SHA-256 扩展出 64 个字就完事;SM3 扩展出 W[0..67],再额外派生一套 W'[j] = W[j] ^ W[j+4]。轮函数里两个都用(W'j 进 TT1、Wj 进 TT2),等于每轮吃两份消息,扩散面更大。
  2. 多了 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
2
3
4
5
6
7
$ printf 'abc' > sm3abc.txt
$ openssl dgst -sm3 sm3abc.txt
SM3(sm3abc.txt)= 66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0

$ printf '' > empty.txt
$ openssl dgst -sm3 empty.txt
SM3(empty.txt)= 1ab21d8355cfa17f8e61194831e81a8f22bec8c728fefb747ed035eb5082aa2b

空串也有摘要,这不是 bug——摘要算法对任何输入都有输出,包括长度为零的输入。这也是它跟加密最根本的区别:加密要能还原,所以必须有密钥、必须有逆运算;摘要只需要能比对,故意把信息量扔掉。

顺手验一下 HMAC-SM3 的雪崩效应(改一个字符、换一个密钥,结果全变):

1
2
3
4
5
6
7
$ printf 'GET /balance HTTP/1.1' > req.txt
$ openssl dgst -sm3 -hmac "secret-key" req.txt
HMAC-SM3(req.txt)= 61cd4cd34f155649f20bac5458079b41167aae6fae5c4ae2033a5eb02ed9683a

$ printf 'GET /balance HTTP/1.2' > req2.txt
$ openssl dgst -sm3 -hmac "secret-key" req2.txt
HMAC-SM3(req2.txt)= 4e482407ed5c82b099aecf898ae1f7f6dea4a02054ab90dbea61c6865acf210e

这里有一个迁移时才想起来的便宜: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,每轮吐出一个新字、挤掉最老的。

X0 X1 X2 X3 X1^X2^X3^rk T = L(τ(·)) S 盒逐字节 + 线性 L X X4 下一轮变成 X1 X2 X3 X4

轮函数一行公式:

1
2
3
X4 = X0 ^ T(X1 ^ X2 ^ X3 ^ rk)
T(·) = L(τ(·)),τ 是 4 个字节各过一遍 S 盒
L(B) = B ^ (B<<<2) ^ (B<<<10) ^ (B<<<18) ^ (B<<<24)

密钥扩展用的是同一套骨架,只是线性层换成 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
2
3
4
5
6
7
8
9
$ printf '0123456789abcdeffedcba9876543210' | xxd -r -p > pt.bin
$ openssl enc -sm4-ecb -K 0123456789abcdeffedcba9876543210 \
-nopad -in pt.bin -out ct.bin
$ xxd -p ct.bin
681edf34d206965e86b3e94f536e4246

$ openssl enc -sm4-ecb -d -K 0123456789abcdeffedcba9876543210 \
-nopad -in ct.bin | xxd -p
0123456789abcdeffedcba9876543210

再看 CBC 模式下长度的变化——75 字节明文,输出 80 字节:

1
2
3
4
5
6
7
$ printf 'SM4 is a 128-bit block cipher, used in TLCP and Chinese national standards.' > msg.txt
$ wc -c msg.txt
75 msg.txt
$ openssl enc -sm4-cbc -K 0123456789abcdeffedcba9876543210 \
-iv 000102030405060708090a0b0c0d0e0f -in msg.txt -out msg.enc
$ wc -c msg.enc
80 msg.enc

75 向上取整到 16 的倍数是 80,多出来的 5 字节是 PKCS#7 填充(差 5 个字节就填 05 05 05 05 05)。这 5 字节同时是长度泄漏的源头,也是为什么真实协议里必须配认证——见坑二。

坑一:SM4 软实现比 AES 慢,而且差距不是一点点

同一台机器、同一个 OpenSSL、只换 -evp 参数,各跑 3 秒:

1
2
3
4
5
6
7
8
$ for alg in sm4-cbc aes-128-cbc sm3 sha256; do
printf "%-12s " "$alg"
openssl speed -evp $alg -seconds 3 2>/dev/null | tail -1 | awk '{print $(NF-1)}'
done
sm4-cbc 85260.98k
aes-128-cbc 301600.41k
sm3 172466.07k
sha256 261808.64k

(最后一列是 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
2
3
4
ZA = SM3(ENTL || ID || a || b || Gx || Gy || Px || Py)   # 把用户身份绑进来
e = SM3(ZA || M)
r = (e + x1) mod n # (x1,y1) = [k]G
s = ((1 + d)^-1 · (k - r·d)) mod n

生成密钥、签名、验签:

1
2
3
4
5
6
7
8
9
10
11
12
$ openssl ecparam -genkey -name SM2 -out sm2key.pem
$ openssl pkey -in sm2key.pem -pubout -out sm2pub.pem

$ printf 'transfer 100 CNY to account 6222' > msg.txt
$ openssl pkeyutl -sign -rawin -digest sm3 -inkey sm2key.pem \
-in msg.txt -out sig.der
$ wc -c < sig.der
71

$ openssl pkeyutl -verify -rawin -digest sm3 -pubin -inkey sm2pub.pem \
-in msg.txt -sigfile sig.der
Signature Verified Successfully

改一个数字再验:

1
2
3
4
5
6
$ printf 'transfer 900 CNY to account 6222' > bad.txt
$ openssl pkeyutl -verify -rawin -digest sm3 -pubin -inkey sm2pub.pem \
-in bad.txt -sigfile sig.der
Signature Verification Failure
100000000A000000:error:030000EA:digital envelope routines:EVP_DigestVerifyFinal:
provider signature failure:crypto/evp/m_sigver.c:703:SM2 digest_verify_final:

注意命令里那个 -digest sm3。它不是可选项——SM2 签名把摘要算法写死在流程里了,换个摘要等于换个签名方案。

坑三:SM2 签名长度不是固定的 64 字节

直觉上 256 bit 的曲线,r 和 s 各 32 字节,签名应该正好 64 字节。实测连签 12 次:

1
2
3
4
5
6
$ for i in $(seq 1 12); do
printf "msg-$i" > m.txt
openssl pkeyutl -sign -rawin -digest sm3 -inkey sm2key.pem -in m.txt -out s$i.der
printf "%s " $(wc -c < s$i.der)
done
71 72 72 70 70 71 70 72 70 70 72 72

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
2
3
4
5
6
7
8
9
10
11
12
$ printf '0123456789abcdef0123456789abcdef' > keymat.bin   # 32 字节
$ openssl pkeyutl -encrypt -pubin -inkey sm2pub.pem -in keymat.bin -out keymat.enc
$ wc -c < keymat.enc
140

$ head -c 1000 /dev/urandom > big.bin
$ openssl pkeyutl -encrypt -pubin -inkey sm2pub.pem -in big.bin -out big.enc
$ wc -c < big.enc
1111
$ openssl pkeyutl -decrypt -inkey sm2key.pem -in big.enc -out big.dec
$ cmp big.bin big.dec && echo ok
ok

1000 字节也能加能解,密文比明文多 111 字节。SM2 加密流程里有 KDF(同样基于 SM3),本质是「椭圆曲线算出共享点 + 用 KDF 拉密钥流做异或」,所以长度没有硬上限。多出来的开销是 C1(一个未压缩椭圆曲线点,约 65 字节)+ C3(32 字节 SM3 摘要)+ 长度编码。

那为什么真实系统里还是「SM2 包 SM4 密钥」?因为慢。一次 SM2 加密要做椭圆曲线点乘,微秒到毫秒级;SM4 是 85 MB/s。用 SM2 加密 10 MB 数据,等于让卡车去送一封信。混合加密才是正解:

1
2
随机生成 16 字节 SM4 密钥 K
密文 = SM4-CBC(K, 数据) || SM2-公钥加密(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 最反直觉的设计是双证书:握手时服务端要出示两张证书,一张用于签名(证明「我是我」),一张用于加密(用于密钥传输)。配置上就是两对文件。

客户端 只持对端公钥 服务端 持两套私钥 签名证书 sign.crt · 证明身份 加密证书 enc.crt · 传输密钥 密钥用途分离:签名长期离线,加密可托管可恢复

为什么要拆两把钥匙?合规上的说法是密钥用途分离:签名密钥是法律意义上的身份凭证,必须长期保存、尽量离线、泄露了要吊销重签;加密密钥用于每次会话,可以被托管、可以恢复、泄露了换一把就好。两件事的风险模型完全不同,用同一把钥匙就是把最严的要求拖累最松的场景。

落到代码上,麻烦才真正开始。一套 TLS 栈的配置模型通常长这样:

1
2
3
/* 一对证书,天经地义 */
SSL_CTX_use_certificate_chain_file(ctx, certfile);
SSL_CTX_use_PrivateKey_file(ctx, keyfile, SSL_FILETYPE_PEM);

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 都不用写。真正需要改代码的地方,永远在「协议怎么用它们」那一层——这也是为什么上面那些坑,全都不在算法里。