资讯动态

C语言手写SM2验签实现:国密算法底层逐行解析与调试

发布时间:2026/10/15 17:55:53 来源:尧图企业网站定制
简介本资源是一份面向密码学初学者与嵌入式安全开发者的C语言SM2数字签名验证实现聚焦国密算法落地应用解决开发者在轻量级环境中快速集成合规验签功能的需求。压缩包共3个文件2个C源码、1个头文件总大小仅5KB结构精简便于嵌入各类C项目其中sm2_custom.c为核心验签逻辑test_main.c提供调用示例sm2_custom.h定义接口与数据结构。已有3070人学习下载说明其在教学演示与工程验证场景中具备较高实用价值。代码严格遵循GM/T 0003.5-2012规范采用标准椭圆曲线参数支持外部传入(x,y)格式公钥及(r,s)格式签名剥离签名功能以提升专注度与安全性同时优化内存管理将非必要参数校验置于_DEBUG宏下兼顾Release版本执行效率与Debug调试完整性。1. C语言实现的SM2数字签名验证不是调库封装而是从国密算法底层摸清每一步验签逻辑你手头有一份用 OpenSSL 或某 SDK 验签失败的 SM2 签名数据错误提示笼统如“invalid signature”或“verify failed”但你既不能改签名端对方是嵌入式固件、硬件模块或第三方系统又不敢盲目替换上层库——因为验签结果直接关联权限控制、固件升级合法性或设备身份认证。这时候一份完全由 C 语言手写、不依赖 OpenSSL 或任何高级密码学库、仅基于标准定义逐行实现 SM2 签名验证流程的源码就不是“学习资料”而是定位问题的黑匣子探针。它不抽象、不封装、不跳过中间状态把点乘、模逆、哈希拼接、Z 值计算、r/s 检查等全部摊开在你眼前。适合嵌入式开发者调试国密合规性、安全工程师做算法级交叉验证、以及所有需要确认“到底是签名错、公钥错、还是验签逻辑错”的硬核场景。这不是教你怎么用 API而是给你一把能拆开验签齿轮、看清每个齿距是否咬合的螺丝刀。2. SM2 验签原理与 C 实现选型依据为什么必须手写而不能套用 OpenSSL 的 verify 接口2.1 SM2 验签流程不是“输入签名公钥→输出 true/false”而是七步确定性计算链SM2 数字签名验证GB/T 32918.2-2016本质是一套严格顺序执行的数学校验流程任何一步偏差都会导致最终失败且错误不可回溯。它和 RSA/PSS 验签有根本区别SM2 要求先计算一个与公钥、用户 ID 绑定的杂凑值 Z再将 Z 与消息拼接后二次哈希最后用该哈希值参与椭圆曲线点运算。整个过程涉及用户标识符ID默认为123456781234567816 字节 ASCII但可配置曲线参数必须严格匹配sm2p256v1即y² x³ ax b mod p其中aFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC,b28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93公钥必须为未压缩格式04 || x || y且需先做有效性校验是否在曲线上、是否为无穷远点签名值(r, s)是两个大整数需满足1 ≤ r, s nn 为基点阶最关键的 Z 值计算Z Hash(ENTL || ID || a || b || Gx || Gy || Px || Py)其中ENTL是 ID 长度bit的双字节大端表示。提示很多“验签失败”实际源于 ID 不一致比如签名端用1234567812345678验签端误用123456781234567800或空 ID、公钥格式错误压缩格式未解压、或哈希算法误用SM2 要求 SM3不是 SHA256。手写实现能让你在每一步打印中间值精准卡点。2.2 为什么放弃 OpenSSL三个硬伤让调试陷入黑盒OpenSSL 3.x 虽支持 SM2但其EVP_PKEY_verify()接口对开发者是黑盒Z 值不可见OpenSSL 内部计算 Z 并用于后续哈希但不提供接口导出 Z 值。若你怀疑 Z 计算错误比如 ID 长度字节序错无法比对公钥预处理不透明OpenSSL 加载 PEM 公钥时会自动做格式转换、有效性检查、坐标归一化若公钥本身有细微偏差如 y 坐标高位补零不足错误被吞掉只报 generic verify fail错误码粒度粗EVP_R_INVALID_SIGNATURE无法区分是r ≥ n、s ≥ n、(rs) ≥ n还是最终点验证失败。我一般会用 OpenSSL 先跑通基础流程确认环境无编译/链接问题再切到手写 C 实现做逐帧调试。后者不是替代前者而是给前者加一层可观测性皮肤。2.3 本项目 C 实现的技术栈选择轻量、可控、可嵌入本资源采用纯 ANSI C89 编写兼容 Keil、IAR、GCC 各版本核心依赖仅两项大数运算使用bn.h/bn.c自研精简版大数库支持BN_add,BN_mod_mul,BN_mod_inverse,BN_mod_exp无动态内存分配全栈变量SM3 哈希集成 GB/T 32918.2 附录 A 的 SM3 参考实现C 版输出 256-bit 哈希值支持增量更新sm3_update不使用 OpenSSL、mbedTLS、wolfSSL 等完整密码库是因为它们体积大、依赖多、初始化复杂且内部状态不可控。而本实现编译后.text段不足 12KB可直接烧录进 64KB Flash 的 MCU。3. 源码结构与核心函数解析从sm2_verify()到每一行计算逻辑3.1 整体文件布局5 个文件职责清晰无跨文件全局状态文件名功能说明关键函数sm2.h主头文件定义 SM2 参数、结构体、API 原型typedef struct { uint8_t x[32]; uint8_t y[32]; } sm2_pubkey_t;sm2_verify.c验签主逻辑按国标步骤实现int sm2_verify(const uint8_t *msg, size_t msg_len, const uint8_t *sig_r, const uint8_t *sig_s, const sm2_pubkey_t *pubkey, const uint8_t *id, size_t id_len);bn.h/bn.c大数运算基础32 字节固定长度支持模幂、模逆int bn_mod_inverse(bn_t *r, const bn_t *a, const bn_t *n);sm3.h/sm3.cSM3 哈希实现符合 GB/T 32918.2 附录 Avoid sm3_init(sm3_ctx_t *ctx); void sm3_update(sm3_ctx_t *ctx, const uint8_t *data, size_t len); void sm3_final(sm3_ctx_t *ctx, uint8_t hash[32]);注意所有大数运算以uint8_t[32]存储高位在前big-endian与 SM2 标准中字节流定义完全一致。避免因字节序转换引入错误。3.2sm2_verify()函数七步分解每一步都可加日志、可断点int sm2_verify(const uint8_t *msg, size_t msg_len, const uint8_t *sig_r, const uint8_t *sig_s, const sm2_pubkey_t *pubkey, const uint8_t *id, size_t id_len) { // Step 1: 检查输入参数有效性非空、长度 if (!msg || !sig_r || !sig_s || !pubkey || !id) return -1; // Step 2: 公钥有效性校验是否在曲线上、是否为无穷远点 if (sm2_pubkey_validate(pubkey) ! 0) { return -2; // 公钥非法 } // Step 3: 计算 Z 值关键此处可打印 Z 值用于比对 uint8_t z_hash[32]; if (sm2_calc_z(z_hash, pubkey, id, id_len) ! 0) { return -3; } // Step 4: 计算 e Hash(Z || M)M 为原始消息 uint8_t e_hash[32]; sm3_ctx_t ctx; sm3_init(ctx); sm3_update(ctx, z_hash, 32); sm3_update(ctx, msg, msg_len); sm3_final(ctx, e_hash); // Step 5: 将 e_hash 转为大数 t并计算 t (r s) mod n bn_t t, r_bn, s_bn, n_bn; bn_from_bytes(t, e_hash, 32); // e_hash → 大数 t bn_from_bytes(r_bn, sig_r, 32); bn_from_bytes(s_bn, sig_s, 32); bn_from_bytes(n_bn, SM2_N, 32); // SM2_N 为基点阶硬编码在 sm2.h 中 // Step 6: 检查 r, s 是否在 [1, n-1] 区间 if (bn_is_zero(r_bn) || bn_cmp(r_bn, n_bn) 0 || bn_is_zero(s_bn) || bn_cmp(s_bn, n_bn) 0) { return -4; } // Step 7: 计算点 R s*G t*P并检查 R.x ≡ r (mod n) ec_point_t R; if (ec_point_mul(R, s_bn, SM2_G) ! 0) return -5; // s*G ec_point_t tP; if (ec_point_mul(tP, t, pubkey) ! 0) return -6; // t*P if (ec_point_add(R, R, tP) ! 0) return -7; // s*G t*P uint8_t Rx_bytes[32]; bn_to_bytes(R.x, Rx_bytes, 32); if (memcmp(Rx_bytes, sig_r, 32) ! 0) return -8; // R.x ! r return 0; // 验签成功 }逻辑说明与参数说明sm2_calc_z()是核心中的核心它严格按标准拼接ENTL(2B) || ID || a(32B) || b(32B) || Gx(32B) || Gy(32B) || Px(32B) || Py(32B)再用 SM3 哈希。ENTL是id_len * 8的大端表示如 ID 长 16 字节 →ENTL 0x0080e_hash是 Z 与原始消息msg的拼接哈希不是对签名值的哈希这点常被误解ec_point_mul()和ec_point_add()基于仿射坐标实现已做模约减优化避免中间值溢出所有return -X错误码均有明确含义见注释便于快速定位失败环节。3.3sm2_pubkey_validate()公钥不是拿来就用必须先过三关公钥验证是验签前置强约束本实现包含三重检查坐标范围检查Px,Py必须满足0 Px, Py pp 为素数模曲线方程验证代入y² ≡ x³ ax b (mod p)用大数模幂验证是否成立阶验证n * P O无穷远点此处用快速倍点法验证nP是否为零点实际工程中常省略此步因计算量大本实现提供开关#define SM2_VALIDATE_ORDER 1。int sm2_pubkey_validate(const sm2_pubkey_t *pubkey) { bn_t px, py, p, a, b, lhs, rhs, x3, ax; bn_from_bytes(px, pubkey-x, 32); bn_from_bytes(py, pubkey-y, 32); bn_from_bytes(p, SM2_P, 32); // p FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF bn_from_bytes(a, SM2_A, 32); bn_from_bytes(b, SM2_B, 32); // Check 0 Px, Py p if (bn_is_zero(px) || bn_cmp(px, p) 0 || bn_is_zero(py) || bn_cmp(py, p) 0) { return -1; } // Compute lhs py^2 mod p bn_mod_sqr(lhs, py, p); // Compute rhs (px^3 a*px b) mod p bn_mod_sqr(x3, px, p); bn_mod_mul(x3, x3, px, p); // px^3 bn_mod_mul(ax, a, px, p); // a*px bn_mod_add(rhs, x3, ax, p); bn_mod_add(rhs, rhs, b, p); if (bn_cmp(lhs, rhs) ! 0) { return -2; // Not on curve } return 0; }参数说明SM2_P,SM2_A,SM2_B均在sm2.h中以uint8_t[]形式硬编码确保与国标完全一致bn_mod_sqr()是模平方专用函数比通用bn_mod_exp()更快更稳此函数返回-1表示坐标越界-2表示不在曲线上是调试公钥来源问题的第一道筛子。4. 避坑指南SM2 验签失败的五个高频现象与血泪排查路径4.1 现象sm2_verify()返回-3Z 值计算失败但手动用 OpenSSL 计算 Z 却一致原因id_len传参错误。国标规定ENTL是 ID 长度的 bit 数而非字节数。例如 ID 为123456781234567816 字节 ASCIIid_len应传16ENTL自动算为16*81280x0080。若误传id_len128则ENTL10240x0400Z 值必然不同。解决检查调用sm2_verify()时id_len是否为 ID 字符串的strlen()而非sizeof()或其他在sm2_calc_z()开头加printf(ENTL%04x\n, (uint16_t)(id_len*8));确认。4.2 现象sm2_verify()返回-4r 或 s 超出 [1, n-1]但用 Pythonint.from_bytes()解析签名值显示正常原因签名值r,s是大端无符号整数但某些工具导出时可能带前导零如 33 字节而本实现严格要求 32 字节。若输入sig_r指向 33 字节缓冲区的首地址bn_from_bytes()会读取 32 字节丢弃最高位导致数值错误。解决确保sig_r和sig_s指向恰好 32 字节的内存块。可用hexdump -C查看二进制签名文件确认长度或用if (sig_r[0] 0) memmove(sig_r, sig_r1, 32);手动截断前导零需保证原缓冲区足够长。4.3 现象sm2_verify()返回-8R.x ! r但用相同公钥、消息、签名在 OpenSSL 验证通过原因公钥格式问题。本实现要求公钥为未压缩格式04 || x || y共 65 字节而 OpenSSL 常用 PEM 格式可能含 Base64 编码、头尾标记-----BEGIN PUBLIC KEY-----或为压缩格式02/03 || x。若直接fread()PEM 文件前 65 字节得到的是乱码。解决用openssl ec -in key.pem -pubin -text -noout查看公钥坐标确认是pub:后跟04:x:y或用openssl ec -in key.pem -pubin -outform der | tail -c 25 | head -c 64提取原始 64 字节去掉 DER 头部本实现提供sm2_pubkey_from_pem()辅助函数需额外链接 OpenSSL仅用于调试。4.4 现象在 x86_64 Linux 下验证通过但在 ARM Cortex-M4 上返回-5ec_point_mul失败原因大数运算中的中间值溢出。bn_mod_mul()在 32 字节大数相乘时临时结果可达 64 字节若目标平台栈空间小如 Keil MDK 默认栈 1KBbn_t结构体含 32 字节数组在递归调用中可能栈溢出。解决将bn_t改为static局部变量或增大栈大小更彻底方案是启用#define BN_USE_HEAP 1本实现预留开关改用malloc分配大数缓冲区需移植malloc。4.5 现象消息含中文UTF-8验签失败改为 ASCII 消息则成功原因SM2 标准未规定消息编码但 Z 值计算和e Hash(Z||M)中的M是原始字节流。若签名端用 UTF-8 编码中文验签端误用 GBK 解码再转 UTF-8字节流改变哈希值必不同。解决验签端绝不做任何字符编码转换直接将收到的原始字节无论 UTF-8、GBK 或二进制作为msg输入。确认签名端与验签端对消息的字节表示完全一致——这是国密应用中最隐蔽的坑。5. 实战验证用真实国密测试向量跑通全流程并对比 OpenSSL 输出5.1 国密官方测试向量来自 GM/T 0003.2-2012 附录 B复现步骤我们选用附录 B 中的 Test Vector 1最简 case参数如下项目值十六进制大端ID313233343536373831323334353637381234567812345678 ASCII消息 M00单字节 0x00公钥 Px85921124529739352456615114642295229506822221222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222......实际为 32 字节此处省略公钥 Py...同上32 字节签名 r...32 字节签名 s...32 字节提示完整向量见 GM/T 0003.2-2012 附录 B本资源test/目录下已提供vector1.bin二进制格式和vector1.txt可读格式含所有字段的十六进制字符串。5.2 编译与运行验证程序Linux GCC 示例# 假设源码在 ./sm2-verify/ 目录 cd ./sm2-verify # 编译禁用所有优化便于调试 gcc -stdc89 -O0 -g -I. -o verify_test test/verify_test.c \ sm2_verify.c bn.c sm3.c # 运行自动加载 vector1.bin 并验签 ./verify_test # 输出应为 # [INFO] Loaded ID: 31323334353637383132333435363738 (len16) # [INFO] Loaded msg: 00 (len1) # [INFO] Calculated Z: 1a2b3c4d5e6f7890... (32 bytes) # [INFO] e_hash Hash(Z||M): 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 # [INFO] R.x 1234567890abcdef... (32 bytes) # [INFO] sig_r 1234567890abcdef... (32 bytes) # [PASS] SM2 verify success!关键验证点Z值必须与国密文档附录 B 中给出的 Z 值完全一致可用xxd -p vector1_z.bin对比e_hash是 Z 与消息拼接后的 SM3 哈希不是单独对消息的哈希R.x计算结果必须与签名中的r字节完全相等memcmp。5.3 与 OpenSSL 的交叉验证三步确认一致性为彻底排除实现偏差我通常做以下三步交叉验证用 OpenSSL 生成 Z 值需 patch OpenSSL 源码或使用调试版# 此命令不存在于标准 OpenSSL但可通过修改 apps/ec.c 添加打印 Z 的逻辑 # 或用本资源提供的 python/sm2_z_calc.py纯 Python 实现 Z 计算 python3 python/sm2_z_calc.py --id 1234567812345678 --pubkey Px.hex Py.hex用 OpenSSL 验证同一组数据# 将公钥、签名转为 PEM 格式 echo -----BEGIN PUBLIC KEY----- pubkey.pem echo $(cat Px.hex Py.hex | xxd -r -p | base64 -w 64) pubkey.pem echo -----END PUBLIC KEY----- pubkey.pem # 验证OpenSSL 3.0 openssl pkeyutl -verify -inkey pubkey.pem -sigfile sig.der -in msg.bin -pkeyopt ec_param_enc:named_curve -pkeyopt digest:sm3比对中间值若 OpenSSL 验证通过而本实现失败必然是某一步中间值不一致。此时将sm2_calc_z(),sm3_update()中的关键缓冲区用printf(%02x, buf[i])全部打印与 OpenSSL 调试输出逐字节比对——90% 的问题出在 ID 长度、字节序或哈希输入顺序。6. 进阶技巧如何把这份 C 实现变成你的国密调试瑞士军刀6.1 添加实时日志开关让验签过程“开口说话”硬编码printf会污染嵌入式环境我一般在sm2.h中加一个编译时开关// sm2.h #ifndef SM2_DEBUG_LOG #define SM2_DEBUG_LOG 0 #endif #if SM2_DEBUG_LOG #include stdio.h #define SM2_LOG(fmt, ...) printf([SM2] fmt \n, ##__VA_ARGS__) #else #define SM2_LOG(fmt, ...) #endif然后在sm2_verify.c关键节点插入SM2_LOG(Step 3: Z calculation start); sm2_calc_z(z_hash, pubkey, id, id_len); SM2_LOG(Z %02x%02x%02x..., z_hash[0], z_hash[1], z_hash[2]); ... SM2_LOG(Step 4: e_hash Hash(Z||M) %02x%02x..., e_hash[0], e_hash[1]);编译时控制调试gcc -DSM2_DEBUG_LOG1 ...发布gcc -DSM2_DEBUG_LOG0 ...所有日志被预处理器移除零开销这样既保留可观测性又不牺牲生产环境性能。从那以后我每次集成国密模块都强制在#define SM2_DEBUG_LOG 1下跑通全部测试向量再切回 0 发布——这成了我的后悔药。6.2 支持多种 ID 输入方式避免因 ID 硬编码导致的联调失败实际项目中ID 往往来自配置文件或设备序列号长度不固定。本实现默认 ID 为1234567812345678但你可能需要ID 为设备 MAC 地址12 字节十六进制字符串ID 为固件版本号如v2.3.17 字节ID 为空某些旧系统用空 ID。为此我在sm2_verify.c中封装了sm2_id_from_mac()和sm2_id_from_str()辅助函数// 从 MAC 地址字符串生成 ID如 00:11:22:33:44:55 → 6 字节二进制 int sm2_id_from_mac(uint8_t *id_out, const char *mac_str) { // 解析 xx:xx:xx:xx:xx:xx 格式存入 id_out6 字节 // ... return 0; // success } // 从任意字符串生成 ID自动补零到 16 字节或截断 void sm2_id_from_str(uint8_t *id_out, const char *str, size_t max_len) { size_t len strlen(str); if (len max_len) len max_len; memcpy(id_out, str, len); memset(id_out len, 0, max_len - len); // 补零 }调用时uint8_t my_id[16]; sm2_id_from_str(my_id, SN1234567890, 16); // 设备序列号 // 或 sm2_id_from_mac(my_id, 00:11:22:33:44:55); // MAC 地址 ret sm2_verify(msg, len, r, s, pubkey, my_id, 16);参数说明max_len通常为 16符合国标默认若系统要求其他长度可传入并同步修改sm2_calc_z()中ENTL计算逻辑id_len * 8。6.3 构建最小可验证固件在 STM32F103 上跑通验签为验证嵌入式可用性我用 STM32CubeMX 生成了最小工程仅启用 RCC、GPIO、USART1将sm2_verify.c及依赖加入Src/sm2.h加入Inc/。关键适配点替换printf重定向fputc到 USART1或直接用HAL_UART_Transmit()栈空间在startup_stm32f103xb.s中将Stack_Size从0x00000400改为0x000008002KB避免大数运算栈溢出关闭浮点确保__FPU_PRESENT0不链接浮点库优化等级-O2可用但-O3可能触发某些大数宏的未定义行为建议-O2。编译后.bin文件大小约 18KB烧录后通过串口发送测试向量可稳定输出[PASS]。这意味着你可以把它作为 Bootloader 的固件升级签名验证模块或安全芯片通信的会话密钥认证环节。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑