4. SM2 加密接口设计与实现总结
SM2 的加密接口,说实话跟 RSA 那套思路差别挺大。RSA 是直接拿公钥加密,SM2 则走了另一条路——它用公钥派生出一个共享密钥,再用对称算法去加密实际数据。很多新手第一次看到输出结果比原文长一大截,心里就会犯嘀咕:“这是不是写错了?”其实没写错,这就是 SM2 的混合加密机制。
我个人习惯在设计接口时,把 SM2 加密拆成三个独立模块:密钥生成、加密、解密。每块职责单一,后期维护起来脑子不累。下面我把每块的核心要点和我在项目中踩过的坑都说清楚。
4.1 密钥生成接口
先聊聊生成密钥对这事儿。SM2 的密钥对由私钥(一个随机大数)和公钥(椭圆曲线上的一个点)组成。接口设计上,我建议直接返回一个结构体,别拆成两个成员变量往外扔——容易弄丢配对关系。
关键点:私钥是 256 位随机数,公钥是 两个 256 位坐标 (x, y) 的拼接。SM2 公钥还有一个特殊的标识字节:04 开头表示非压缩格式,02 或 03 开头表示压缩格式。我个人推荐统一用非压缩格式,省去解压缩的麻烦。
密钥生成的伪代码大概像下面这样。注意,随机数发生器一定要用国密局批准的硬件熵源,别用系统自带的 rand()——我在一个早期项目里吃过这个亏,跑测试向量总是不通过,最后发现是随机数质量不达标。
// 密钥对生成接口
typedef struct {
uint8_t private_key[32]; // 私钥,256位大端字节序
uint8_t public_key[65]; // 公钥,04 || x(32字节) || y(32字节)
} SM2_KEYPAIR;
int sm2_generate_keypair(SM2_KEYPAIR *kp) {
// 确保使用了国密认证的随机数发生器
if (国密随机数生成(kp->private_key, 32) != 0) {
return -1; // 随机数生成失败
}
// 私钥范围检查:应满足 1 < d < n-1
// 实际开发中这一步往往被忽略,但它能防止奇偶性攻击
if (!私钥在合法范围内(kp->private_key)) {
return -2; // 私钥越界
}
// 计算公钥:P = d * G
// 这里调用了底层的椭圆曲线标量乘法
sm2_point_multiply(kp->private_key, &(kp->public_key));
// 设置公钥标识为非压缩格式
kp->public_key[0] = 0x04;
return 0;
}
| 参数 | 字节数 | 说明 |
|---|---|---|
| 私钥 | 32 字节 | 大端序,范围 [1, n-1],n 为 SM2 曲线的阶 |
| 公钥(非压缩) | 65 字节 | 首字节 0x04,后接 x 和 y 坐标各 32 字节 |
| 公钥(压缩) | 33 字节 | 首字节 02 或 03,后接 x 坐标 32 字节;y 通过曲线方程恢复 |
避坑指南:我曾经在生产环境中遇到一个 case——密钥对生成后直接存文件,没做一致性校验。结果某次磁盘写入异常,私钥丢了一位,公钥却感觉变化不大。客户端用这把公钥加密数据,服务器端拿着“看似正确”的私钥解密,死活解不出来。从那以后,我每次生成完密钥对都会用 解密-加密互验 打一遍:用公钥加密一个固定向量,再用私钥解密,比对结果。这一步虽然多花了微秒级时间,但能截住绝大多数位翻转问题。
4.2 加密接口设计
SM2 加密比一般非对称加密多了一个步骤:它内部用 KDF(密钥派生函数) 生成对称密钥,然后调用 SM4 或 XOR 模式 去加密明文。这也就是为什么输出的密文长度比明文多出一大截——多了公钥点(65 字节)和校验值(32 字节)。
我画了一张流程图来展示加密的内部通路。你看看就明白了。
从图上不难看出,加密结果由三部分拼接而成:C1(曲线点,通常65字节)、C3(SM3哈希值,32字节)、C2(密文,与明文等长)。你设计的接口返回缓冲区必须能装下 65 + 32 + 明文长度 这么多字节。
4.3 解密接口设计
解密是加密的逆过程,但有一点容易被忽略:解密必须先提取 C1 中的椭圆点,用私钥计算共享点,然后再次派生对称密钥。这个过程如果私钥格式不对或者 C1 的点不在曲线上,会直接导致解密失败。
我在代码里做了三重防御,你参考下:
- 点校验——检查 C1 是否在 SM2 曲线上,防止无效点注入攻击
- 私钥范围——再次确认私钥在
[1, n-1]区间内 - 哈希验证——解密完明文后重新计算 SM3,跟 C3 对比,如果不一致就说明数据被篡改
源码里大概长这样:
int sm2_decrypt(const uint8_t *ciphertext, size_t ct_len,
const SM2_KEYPAIR *kp, uint8_t **plaintext, size_t *pt_len) {
// 1. 解包 C1(前65字节)
// 2. 校验 C1 是否在曲线上 — 这一步不能省!
if (!sm2_point_on_curve(ciphertext)) {
return -1; // 无效的曲线点
}
// 3. 计算共享点 S = d * C1,其中 d 是私钥
sm2_point_t S;
sm2_point_multiply(kp->private_key, ciphertext, &S);
// 4. KDF 派生对称密钥,长度等于密文长度减97(65+32)
size_t key_len = ct_len - 97;
uint8_t *key = malloc(key_len);
sm2_kdf(S.x, S.y, key_len, key);
// 5. XOR 解密得到明文
*plaintext = malloc(key_len);
for (size_t i = 0; i < key_len; i++) {
(*plaintext)[i] = ciphertext[97 + i] ^ key[i];
}
// 6. 重新计算哈希,跟 C3 比对
// 注意 C3 是 SM3(x || 明文 || y),x 和 y 都是32字节
uint8_t c3_calc[32];
sm3_hash(S.x, 32, *plaintext, key_len, S.y, 32, c3_calc);
if (memcmp(c3_calc, ciphertext + 65, 32) != 0) {
free(*plaintext);
free(key);
return -3; // 完整性校验失败
}
*pt_len = key_len;
free(key);
return 0;
}
技巧:你想想看,为什么国密标准要求 C3 计算时要把 x 和 y 分别放到明文两侧?这其实是为了防止“重排攻击”。如果攻击者偷偷调换了明文前后的坐标值,哈希一跑就会暴露。嗯,设计得很精巧。
4.4 数据封装格式
最后聊一下数据封装。SM2 标准并没规定密文在传输时必须用什么容器,但在实践中,我见过三种主流做法:
| 封装方式 | 结构 | 适用场景 |
|---|---|---|
| ASN.1 DER | SEQUENCE { C1, C3, C2 } | 国密证书、PKI 体系(业界最通用) |
| TLV 自定义 | Tag(0x01) || Len || C1 || Tag(0x02) || Len || C3 || Tag(0x03) || Len || C2 | 物联网、资源受限设备 |
| 固定偏移拼接 | [0-64] C1 + [65-96] C3 + [97-end] C2 | 内部系统、高速接口 |
我个人更倾向于 ASN.1 DER,因为可读性强,而且跟国密证书无缝衔接。不过如果你的场景是低功耗嵌入式设备,固定偏移拼接更省 CPU,毕竟 DER 解析会多出几十微秒的开销。
最终建议:把密钥生成、加密、解密封装成三个独立函数,对外只暴露 sm2_encrypt() 和 sm2_decrypt()。内部把随机数生成、点计算、KDF、哈希比对这些脏活都藏起来。调用方只需要关心:公钥进来,密文出去;私钥进来,原文出去。这样接口才真正做到了“看一眼就会用”。