简介SM9算法源代码及配套解析文档围绕国产商用密码算法SM9提供基于标识密码IBC的完整C语言实现与分层讲解资料算法以用户ID作为公钥省去传统PKI中的证书管理环节适合大规模分布式环境及密码学学习者、安全研发人员使用。压缩包共56个文件、约4.11MB内含4个C源代码文件和8个H头文件覆盖公钥私钥生成、加解密、签名验签、密钥封装与密钥协商等核心流程可直接参考或移植另有44个PDF文档按加解密、封装、签名验签、密钥协商四个模块拆分讲解重点剖析R-ate双线性配对、KDF密钥派生、Zzn12域运算以及SM4辅助加密等底层实现原理并与源码形成对照方便从数学基础到工程实现系统掌握。目录模块化组织定位清晰已有276人学习下载对需要深入理解SM9工作流程、在物联网或云环境下落地基于ID的密码方案的研究人员与开发者而言是一份兼顾原理和代码的实用资源。1. 这个 zip 里的 SM9 源码到底在实现什么一个带“SM9算法源代码”字样的 zip 包通常不是完整可安装的软件而是一组 C 语言源码加上测试工程。它对应国密标准 GM/T 0044 里定义的基于标识的密码体制IBC和传统 PKI 最大的区别在于公钥不需要存放在证书里而是直接用用户的邮箱、手机号甚至设备序列号当作公钥。换句话讲只要知道对方标识就能加密或验签。很多工程师第一次解压这种包时会被 sm9_rpc.c、bn_curve.c、zzn 之类文件弄晕不知道从哪看起。其实 SM9 源码内部就三件事从标识算出公钥、由密钥生成中心KGC为标识颁发私钥以及用这套密钥完成签名、加密和密钥交换。理解了这个模型再去读代码目录结构和函数名就都有意义了。下面从一个普通开发者的角度讲怎么把这种 zip 里的源码编译跑通把签名验签和加密解密的调用关系拆开并给出参数设置和排错方法。内容适合自己写国密适配层、做内部安全模块或者只是想在设备端集成 SM9 的读者。2. 从 zip 到三个核心对象源码结构、KGC 和密钥材料拿到 zip 之后我一般先解压到单独目录看根目录下有没有 README 或 Makefile。没有也不慌SM9 源码的模块边界非常清晰只要按算法阶段去找文件通常十分钟能定位到入口。2.1 zip 解压后常见的文件和职责根据我处理多个国密项目源码包的经验SM9 源码 zip 里基本会重复出现以下几类文件文件类职责备注sm9.h / sm9_types.h对外 API 与结构体定义从这里找函数原型而不是在 .c 文件里找sm9_curve.c / sm9_pair.c椭圆曲线点乘与双线性对SM9 的运算热点代码量最大sm9_kdf.c密钥派生函数基于 SM3负责从临时值派生加密或签名密钥流sm9_kgc.c主密钥与用户私钥生成主要调 H1 和曲线点乘test_sm9.c自测入口包含固定测试向量时最值钱Makefile / CMakeLists.txt构建脚本决定是否依赖 GMP、OpenSSL 等第三方库这些命名不是标准但功能可以对应。比如 bn.c、mp.c、zzn2.c 都叫“大数/扩域库”具体看每个文件顶部的注释。2.2 IBC 公钥一串可读标识如何映射到曲线上的点SM9 里用户标识是任意字节串。“aliceexample.com”就是一个公钥任何知情者用它就能加密。不过标识必须映射到曲线上映射过程用一个名叫 H1 的函数先拼接 hid 和标识做 SM3 运算得到一个整数然后尝试把它当作曲线点的横坐标如果这个点不在曲线上就加一继续试。源码里这个步骤常常长这样/* * H1: 将标识 id 映射为曲线上的点。 * hid 区分用途1签名请求3加密请求。 * n 是循环次数上限防止出现无穷循环。 */ int H1_to_point(const unsigned char *id, int id_len, unsigned char hid, EC_POINT *point, BIGNUM *order) { unsigned char hash[32]; int counter 0; BIGNUM *x BN_new(); do { SM3(id, id_len, hid, counter, hash); /* 拼 hid 和 counter */ BN_bin2bn(hash, 32, x); } while (!EC_POINT_set_compressed_coordinates(curve, point, x, 0, NULL) counter 255); BN_free(x); return counter 255 ? 0 : -1; }说明这里把 counter 放进哈希计算是为了避免曲线当前没有对应点时死循环。这个函数的输入输出决定了后续所有公私钥派生如果 id_len 传错验签和加密都救不回来。2.3 KGC 与用户私钥一切安全的重心在基于标识的密码体系里KGC 持有系统主私钥 ks公开主公钥 Ppub ks * P2。P2 是曲线群的生成元ks 泄露就相当于所有人的私钥都能被推导所以多数源码会提示把主私钥放到 HSM 或加密文件里。用户私钥由 KGC 生成并离线下发结构上是一个 G2 群点。/* 典型的 SM9 用户私钥结构G2 坐标用 x 和 y 表示 */ typedef struct sm9_privkey_st { unsigned char id[128]; /* 标识原文如手机号 13800001111 */ unsigned short id_len; /* 标识字节长度 */ unsigned char hid; /* 用途标识签名/加密/密钥交换 */ BN_ULONG x[4]; /* 扩域元素 x实际是 fp2 */ BN_ULONG y[4]; /* 扩域元素 y */ } SM9_PRIV_KEY;实际项目里用户私钥一般不会存明文而是用 PIN 码派生密钥后封装。源码包自带的 KGC 只是演示导出私钥文件时要注意内存清零。2.4 源码里的曲线与配对和 SM2 有什么区别SM9 使用的曲线是 BN 曲线基域 256 位但运算发生在嵌入次数为 12 的扩域 G_T 上。源码里只要有 fp12、Miller 循环、final_exp 这三个关键词就是在做双线性对。SM2 源码不会有这些。如果在 zip 的 .c 文件里找不到 “pair” 或 “final_exp” 字样那大概率是二方加密代码不是完整的 SM9 实现。SM9 的配对运算很慢是 SM2 点乘的数十倍。性能优化一般集中在 pairing 函数上而不是 KDF 或哈希。编译时用 -O2 和多线程不能直接提升配对速度后面第五章会讲一个预计算技巧。3. 把签名和验签代码跑起来关键步骤、参数与三个常见坑3.1 最小调用框架SM9 签名算法使用随机数 k签名结果是 (h, S) 二元组。验签时只需要主公钥、用户标识以及消息受测方不需要暴露私钥。下面是一段接近大多数源码包 API 的调用示例注意函数名不一定完全一致但流程是固定的#include sm9.h #include stdio.h #include string.h int main(void) { sm9_init(); /* 装载曲线方程和配对函数表 */ sm9_master_key mk; sm9_public_key pk; sm9_master_key_generate(mk); /* 随机生成主私钥 ks */ sm9_public_key_extract(pk, mk); /* 计算主公钥 Ppub */ /* KGC 为用户 alice 生成签名私钥hid1 表示签名 */ sm9_privkey sk; const char *alice alicetest.com; sm9_sign_keygen(mk, (const unsigned char *)alice, strlen(alice), 1, sk); unsigned char msg[] hello sm9; sm9_signature sig; sm9_sign(sk, msg, sizeof(msg), sig); int ret sm9_verify(pk, (const unsigned char *)alice, strlen(alice), 1, msg, sizeof(msg), sig); printf(verify ret %d\n, ret); /* 0 表示验签成功 */ sm9_privkey_clear(sk); return ret; }这段代码里的第 1 步sm9_init常被省略但在没有全局构造函数的环境里比如单片机缺了它会导致后面的配对结果全部为 1。第三步hid1必须和验签时的 hid 一致换一个 hid 验签必挂不是代码 bug。3.2 签名与验签的关键参数参数典型值错误时现象hid签名用 1加密用 3验签失败且所有语句一致失败标识长度实际字节数而不是 printf 的长度私钥派生不同验签失败消息输入二进制安全不能以字符串截断多一个空格/截 0 都失败签名结构通常是 h注意SM3 输出 32 字节SM9 的 h 是 32 字节S 是 64 字节扩域元素签名总长不是 96 就是 64取决于实现是否压缩坐标。源码里如果写成 “SM9_SIG_SIZE 64”意味着 S 只存了 G1 的未压缩坐标中的 x 坐标加符号位。3.3 拿到源码先检查的四个位置第一检查大数字节序。很多源码把曲线元素的内部表示设置为大端但采用 GMP 的版本可能是小端换到 ARM 平台时如果移植代码没有统一转换验签会“偶尔失败”。第二检查 KGC 生成私钥时传入的 hid。如果测试代码里生成私钥时 hid 传了 1验签时传了 3结果一定错。第三检查随机数来源。签名必须使用高质量随机数如果用常数随机数雅可比坐标下的点变换会让每条签名语句不同但所有签名语句都来自同一随机数存在重放风险。第四看源码有没有把对运算结果缓存。如果没有批量验签时 CPU 时间会线性增长这为后面的优化留下了空间。3.4 编译时遇到 undefined reference、undefined symbol 怎么办常见做法是先用仓库自带的 Makefile如果缺文件再手动编译gcc -O2 -c sm9_curve.c gcc -O2 -c sm9_kdf.c gcc -O2 -c sm9_sign.c gcc -O2 -o test_sm9 test_sm9.c sm9_curve.o sm9_kdf.o sm9_sign.o -lgmp如果报undefined reference to __gmpz_*说明大数依赖 GMP需加上-lgmp。如果报的符号是sm3_*说明需要 SM3 参考实现把 sm3.c 也加进编译列表。不要急着改算法代码先从头文件里查看目标函数原型确认是声明不一致还是没实现。4. 加密和密钥交换SM9 源码的另一半4.1 加密消息的格式SM9 加密输出通常组装为 C1||C2||C3也有版本写成 C1||C3||C2。前者是标准推荐后者是国密官网早期文档的顺序很多工程链路不兼容就是在这里产生了分叉。拿到源码后先看 test_sm9.c 里对加密结果的断言确定实现的顺序再用。C1 是 G1 上的临时公钥点C2 是密文正文C3 是对原文的 SM3 摘要。解密时先校验 C3再算 C2不要直接改乱序。4.2 KDF 的入参和派生长度SM9 加密和密钥交换都依赖密钥派生函数 KDF它由 SM3 迭代构成输入是双线性对计算出来的一个临时值 w。KDF 最容易被改坏的地方是长度参数klen 是读取消息时从密文推出来的不是固定 16 或 32。源码里的函数签名经常是int sm9_kdf(const unsigned char *z, unsigned int zlen, unsigned int klen, unsigned char *out);注意 KDF 内部要先计算 ceil(klen / 32) 来决定哈希迭代次数最后一段可能需要高位补零而不是仅截断。如果实现里直接 SM3(z) 一次当成密钥流解密会乱码。4.3 最小加密解密调用示例和签名不同SM9 加密不需要 KGC 参与第二阶段加密方拿到主公钥和用户标识即可。下面用一个贴近源代码包的最小代码展示unsigned char msg[] confidential; unsigned char cipher[256]; unsigned char plain[256]; int clen, plen; /* 加密输入主公钥 pk、用户标识 alice、hid3 */ clen sm9_encrypt(pk, alice, strlen(alice), 3, msg, sizeof(msg), cipher); /* 解密输入用户私钥 sk_enchid3 对应的私钥 */ plen sm9_decrypt(sk_enc, cipher, clen, plain); if (plen sizeof(msg) memcmp(plain, msg, sizeof(msg)) 0) { printf(enc/dec ok\n); }这里hid3决定了加密使用的 KGC 私钥体系。签名时用的 hid1 私钥不能用来解密这是初学者最容易踩的混用问题。4.4 哪些代码能和签名复用哪些不能签名和加密共享 H1、曲线运算、双线性对和 SM3但用户私钥类型不同公钥提取需要的 Ppub 也不同。源码里常见做法是让签名私钥和加密私钥都继承同一个 G2 点结构但 hid 字段必须分开。如果自己实现建议把所有 KGC 私钥导出函数统一封装成kgc_derive(ks, id, idlen, hid, out)避免签名私钥被传到加密 API。在复用时注意头文件里有没有开SM9_TEST_MODE宏打开后签名随机数会固定为测试值这只能用于自测不能用于生产。项目签名加密hid13用户私钥域G2G2主公钥用途验签加密随机数签名的临时数 k加密临时数 r从上表可以直观看到签名和加密的流程骨架基本一样但 hid、随机数的语义都不同。源码里如果只实现了签名部分就声称“SM9 算法原码”一般还缺 KDF 和加密组装模块需要自己补齐。5. 用测试向量验证 SM9 源码并在批量验签时提速5.1 用自测向量锁定实现正确性验证 SM9 源码最快的方法是构造“固定随机数 固定主私钥”的测试。大多数 zip 包里的 test_sm9.c 会带一组测试向量主私钥、主公钥、用户标识、私钥、消息和签名。运行make test或./test_sm9 -v后把输出中的 h 和 S 与文档比对。如果没有现成向量建议生成一组临时向量再修改源码把随机数固定为 0x12345678 对照输出。注意这个技巧只适用于自测生产环境必须回到真随机数。如果不想改代码可以找一个可信的第三方 SM9 工具例如 GmSSL 的命令行来交叉验证。用相同主私钥和相同消息导出的验签摘要应完全一致。5.2 批量验签时预计算双线性对SM9 验签的耗时集中在配对运算上。一批消息如果来自同一个用户主公钥固定H1(id) 也固定最终幂只需要做一次。优化思路是把 e(Ppub, H1(id)) 预先算好缓存到内存。之后每条消息验签时只需要比对 q 的关系能省掉大部分配对时间。/* 针对固定签名者做预计算之后验签不再重复计算对 */ pair_precomp_t pc; pairing_precompute_e(pc, Ppub, H1_id); for (int i 0; i batch_size; i) { ret sm9_verify_with_pc(pc, msg[i], len[i], sig[i]); }用 C 语言实现时可以把预计算结构体放进一个哈希表键为主公钥加标识的 SHA256 值。这个优化在解密之外同样有效生产环境里批量验签可以提速 30% 至 50%。5.3 一个可复现的小技巧验签结果与源码版本绑定为了确认线上跑的是不是这份源码可以在验签回调里增加一段摘要计算将验证结果、消息前 8 字节和 unix 时间戳拼接后 SM3输出成一个 32 字节的十六进制串。这样验签日志里每一行都带一个指纹对比不同版本的日志就能发现哪个模块在使用旧 SM9 实现。/* 生成一段固定长度的审计关键值 */ void audit_sm9_hash(const unsigned char *msg, int msg_len, int ret, unsigned char out[32]) { SM3_CTX ctx; uint32_t ts (uint32_t)time(NULL); sm3_init(ctx); sm3_update(ctx, msg, msg_len 8 ? msg_len : 8); sm3_update(ctx, (unsigned char *)ts, 4); sm3_update(ctx, (unsigned char *)ret, 1); sm3_final(ctx, out); }这段代码展示思路把验签结果和消息源绑定生产环境保存整个 32 字节摘要。读日志时用grep audit过滤出所有 SM9 验签审计记录即可。本文还有配套的精品资源点击获取