SM9国密算法源码解析:从标识到签名验签与加密实现
2026/9/15 4:23:18 网站建设 项目流程

简介: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 里,用户标识是任意字节串。“alice@example.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 生成签名私钥,hid=1 表示签名 */ sm9_privkey sk; const char *alice = "alice@test.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。第三步hid=1必须和验签时的 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、hid=3 */ clen = sm9_encrypt(&pk, alice, strlen(alice), 3, msg, sizeof(msg), cipher); /* 解密:输入用户私钥 sk_enc(hid=3 对应的私钥) */ plen = sm9_decrypt(&sk_enc, cipher, clen, plain); if (plen == sizeof(msg) && memcmp(plain, msg, sizeof(msg)) == 0) { printf("enc/dec ok\n"); }

这里hid=3决定了加密使用的 KGC 私钥体系。签名时用的 hid=1 私钥不能用来解密,这是初学者最容易踩的混用问题。

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 验签审计记录即可。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询