国密对接花掉的时间,绝大部分不在算法上——SM2、SM3、SM4 的实现都很成熟,各家算出来的数学结果是一致的。耗时间的是四个各家不统一的约定。
先看清 SM2 密文的结构
SM2 密文由三段拼成:
| 段 | 内容 | 长度 |
|---|---|---|
| C1 | 加密时生成的临时椭圆曲线点 | 64 字节(带 04 前缀则 65) |
| C2 | 与明文等长的密文 | = 明文长度 |
| C3 | SM3 摘要,用于完整性校验 | 恒定 32 字节 |
关键在于:C1 和 C3 的长度是固定的。这给了我们一条从长度倒推格式的路。
坑一:C1C2C3 还是 C1C3C2
现行规范要求 C1C3C2,但线上大量存在 C1C2C3。
分歧的来源很具体:SM2 算法标准 GM/T 0003-2012 在描述算法时按 C1、C2、C3 的顺序讲,早期实现就照着这个顺序拼接;后来 GM/T 0009-2012《SM2 密码算法使用规范》明确规定密文排列是 C1 || C3 || C2。
BouncyCastle 早期版本只有 C1C2C3,到 1.57 才加入 SM2Engine.Mode.C1C3C2。所以两套并存,而且都还在生产环境里跑。
能自动判别吗?不能。 两种排列的 C1 都在最前面,总长度也完全一样,从结构上区分不了。
但试错的代价很低。 SM2 解密会用 C3 做完整性校验——排列选反了会直接校验失败并报错,而不是安静地吐出一段错误明文。对调一下再试就行,不存在「悄悄解错了还不知道」的风险。
坑二:开头那个 04
04 是未压缩椭圆曲线点的标准前缀,来自 SEC 1 的点编码约定:
04开头 → 未压缩,X 和 Y 都给全(64 字节)02/03开头 → 压缩,只给 X,用 02/03 区分 Y 的奇偶(32 字节)
C1 本质就是一个曲线点,输出时带不带这个前缀纯属实现选择。带 04 的密文比不带的长 1 字节,Hex 串长 2 个字符。
这个是可以自动判别的,而且判据很强:取开头 64 字节当作 (X, Y),验证它是否落在 SM2 曲线上;再取第 2 到第 65 字节验证一遍。只有正确的那一种能通过——落在曲线上是个极强的约束,随机数据几乎不可能碰巧满足。
本站工具就是这么做的:不靠长度猜,而是拿曲线方程验一遍。
坑三:以 30 开头的 DER 编码
如果密文以 30 开头、而且长度还不固定,那它是 ASN.1 DER 编码,不是裸拼接。
0x30 是 DER 里 SEQUENCE 的标签字节。GM/T 0009-2012 除了裸拼接还定义了一种 ASN.1 表示:
SEQUENCE {
x INTEGER
y INTEGER
hash OCTET STRING (32)
cipherText OCTET STRING
}
Java 生态里 DER 很常见,因为 BouncyCastle 的输出默认带 ASN.1 结构;JavaScript 和 Go 的国密库则更多直接给裸拼接。
为什么长度会浮动:DER 的 INTEGER 是有符号的。当 x 或 y 的最高位是 1 时,编码要在前面补一个 0x00 字节,否则会被当成负数。所以同一对坐标可能编码成 32 字节也可能 33 字节。
这解释了一个常见困惑:同一份明文加密多次,DER 密文的总长度会在几个字节之间浮动——这是正常的,不是数据损坏。
坑四:签名的 userId 和编码
SM2 签名验不过,在密钥和数据都确认无误之后,剩下的基本就是这两个。
userId 参与 Z 值计算
SM2 签名不是直接对消息做 SM3。它先算一个 Z 值:
Z = SM3( ENTL || ID || a || b || xG || yG || xA || yA )
然后对 Z || 消息 做 SM3,再对这个摘要签名。
其中 ID 就是用户标识。GB/T 32918.2 给出的默认值是 ASCII 字符串 1234567812345678(16 字节,对应的 ENTL 是 0x0080)。绝大多数库默认用它,但有的实现允许业务自定义,有的用了别的默认值。
两端 ID 不一致,Z 值就不同,签名必然验不过。 而报错只会说「验签失败」,不会提示是 ID 的问题——这是最难猜的一个坑。
签名的两种编码
签名本质是 (r, s) 一对数,有两种写法:
| 编码 | 形态 | 常见于 |
|---|---|---|
| 裸拼接 | r || s,正好 64 字节 / 128 个 Hex 字符 | 前端国密库 |
| DER | SEQUENCE { r INTEGER, s INTEGER },以 30 开头,70–72 字节浮动 | Java / BouncyCastle |
判别很简单:长度正好 128 个 Hex 字符就是裸拼接;以 30 开头且长度浮动就是 DER。
一条完整的排查路径
1. 密文以 30 开头?
├─ 是 → 先做 DER 解码,得到裸的三段,回到第 2 步
└─ 否 → 继续
2. 算长度:Hex 字符数 ÷ 2 = 总字节数
总字节数 − 96 = ?
├─ 等于明文字节数 → 没有 04 前缀
└─ 比明文字节数多 1 → 带 04 前缀
(明文按 UTF-8 算,一个汉字 3 字节)
3. 用曲线校验复核:
取出认定为 C1 的 64 字节,验证 (X, Y) 是否在 SM2 曲线上
4. C1C3C2 和 C1C2C3 各试一次
→ 能通过 C3 完整性校验的那个就是对的
5. 签名问题:先对 userId,再看长度判编码
第 2 步里「明文是字节不是字符」这一条容易翻车——一个汉字按 UTF-8 是 3 字节,算错会让整条推导跑偏。
SM4 那边的情况
SM4 的坑比 SM2 少得多。它的结构和 AES 几乎一样:128 位分组、128 位密钥,支持 ECB / CBC / GCM,填充也是 PKCS7。所以 AES 的那套对接问题原样适用——密钥按 UTF-8 还是 Hex 解释、CBC 的 IV 从哪来、填充用哪种、输出编码是什么。
唯一值得单说的是密钥长度:SM4 只有 128 位一种,没有 192 和 256 的变体。看到「SM4-256」这种说法,基本可以判定对方搞错了。
SM4-GCM 需要额外传 nonce 和 16 字节认证标签,漏传标签是常见的失败原因,和 AES-GCM 一模一样。
用工具把变量固定下来
国密 SM2/SM3/SM4把上面几个变量都做成了显式选项:
- 密文排列可选 C1C3C2 或 C1C2C3
- ASN.1 DER 编码可开可关
- 签名编码可选裸拼接或 DER
- userId 可以自定义,默认是国标的
1234567812345678 - 密文会按段拆开展示 C1 / C3 / C2,直接看出各段长度对不对
04前缀靠曲线校验判别,不靠长度猜
SM3 用 GM/T 0004 的官方测试向量验证,SM4 用 GB/T 32907 的官方向量验证,全部在浏览器本地计算,密钥不上传。
排查时的建议顺序是:先用工具把对方给的密文解出来——这一步成功就意味着四个变量全部对齐了;然后用同一组设置加密自己的数据发回去。中间任何一步失败,对照上面那条路径逐项排除。
如果你还在判断这个项目到底要不要上国密、三个算法各自对应哪份标准,见 国密 SM2/SM3/SM4 到底什么时候必须用。