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 字节)。

我画了一张流程图来展示加密的内部通路。你看看就明白了。

SM2 加密流程结构图 SM2 加密流程结构图 明文 + 公钥 生成随机数 k 计算 C1 = k * G (椭圆点) 计算 S = k * 公钥 (共享点) KDF(S.x || S.y) → t 对称密钥(长度同明文) C2 = t ⊕ 明文 输出 C1 || C3 || C2 C3 = SM3(x || 明文 || y) 用于完整性校验

从图上不难看出,加密结果由三部分拼接而成:C1(曲线点,通常65字节)、C3(SM3哈希值,32字节)、C2(密文,与明文等长)。你设计的接口返回缓冲区必须能装下 65 + 32 + 明文长度 这么多字节。

4.3 解密接口设计

解密是加密的逆过程,但有一点容易被忽略:解密必须先提取 C1 中的椭圆点,用私钥计算共享点,然后再次派生对称密钥。这个过程如果私钥格式不对或者 C1 的点不在曲线上,会直接导致解密失败。

我在代码里做了三重防御,你参考下:

  1. 点校验——检查 C1 是否在 SM2 曲线上,防止无效点注入攻击
  2. 私钥范围——再次确认私钥在 [1, n-1] 区间内
  3. 哈希验证——解密完明文后重新计算 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、哈希比对这些脏活都藏起来。调用方只需要关心:公钥进来,密文出去;私钥进来,原文出去。这样接口才真正做到了“看一眼就会用”。