简介:面向国密算法学习与工程落地的开发者,这份资料包围绕SM2椭圆曲线公钥密码、SM3杂凑算法和SM4分组密码展开,兼顾原理理解、代码实现与结果验证。包内共78个文件,约6.99MB,以C语言源码和头文件为核心,配套Java实现、JavaScript示例、算法规范PDF、需求说明文档以及MFC验证工具,覆盖从规范查阅、多语言对照编写到本地测试的完整链路。已有854人学习下载,在国密入门和项目参考场景中具有一定认可度。读者可据此梳理SM2密钥生成、签名验签与加解密流程,理解SM3摘要计算和SM4分组加解密的关键参数,借助现成代码完成功能复现,并通过测试代码与验证工具核对中间结果、排查填充或编码差异;配套文档还有助于把握技术规范、曲线参数和实现注意事项,适合课程实验、项目预研及自测对照使用。
1. 一份纯 C 的 SM2/SM3/SM4 源码包:我在密码改造里为什么留着它
给一套内部系统做密码改造,业务层改完了,最后卡在应用层:原来调 SHA-256 的地方要换 SM3,AES 换 SM4,RSA 签名换 SM2。找了一圈封装库,要么拖着几个第三方依赖进来,要么只给二进制不给源码,审计那关过不去。最后落到手上的是一份纯 C 的国密算法 SM2/SM3/SM4 实现包——三个算法各自独立成模块,只依赖标准 C 库,带已知答案测试向量和能直接跑的示例程序。
它解决的不是“国密是什么”,而是“怎么在现有 C 工程里把三个算法接进去、并且和对面系统对上”。适合三类人:做存量系统密码改造的后端、写终端固件要裁体积的、以及必须自己把实现审一遍的。下面按编译、跑通、对接、排错的顺序拆。
2. 三个算法不是一套东西:SM2、SM3、SM4 的边界与包内模块对应
很多人拿到这类包的第一反应是“三个算法三个函数,挨个调一遍就完事”。真接起来会发现三者根本不在一个层次上:SM3 是摘要,SM4 是对称分组密码,SM2 是非对称的公钥体系,而且 SM2 内部还要调用 SM3。搞不清这层依赖关系,后面排错会一直在错误的方向上打转。
2.1 SM3 的 Merkle–Damgård 结构决定了它能干什么、不能干什么
SM3 输出 256 bit,分组 512 bit,内部是典型的 Merkle–Damgård 迭代:把消息填充到 512 bit 的整数倍,逐块喂进压缩函数,最后输出 8 个 32 bit 字的串联。填充规则是先在末尾补一个 0x80 字节,补零到长度 ≡ 56 (mod 64),最后 8 字节写入原始消息的比特长度(大端)。压缩函数 64 轮,前 16 轮常量 T 取 0x79cc4519,后 48 轮取 0x7a879d8a,布尔函数 FF/GG 也在这两段切换,置换函数 P0 做 9/17 位循环左移异或,P1 做 15/23 位。
这个结构带来两个直接后果。第一,它是流式的,update可以任意长度调用,内部自己攒 64 字节的块,这一点对处理文件和 socket 特别友好。第二,MD 结构天然有长度扩展问题:拿到SM3(key || msg)的结果,攻击者可以在不知道 key 的情况下继续往后拼消息算出合法摘要。所以别把 SM3 直接当 MAC 用,要做完整性校验就走 HMAC-SM3,或者干脆“先加密再摘要”。国密体系里的 KDF 也是基于 SM3 的计数器模式,不是简单拼接,这个细节在 SM2 加密那一节还会碰面。
2.2 SM4 的 32 轮和密钥扩展:解密为什么只是倒着用轮密钥
SM4 分组 128 bit、密钥 128 bit,32 轮迭代,加解密共用同一套轮函数,这是它跟 AES 在设计取向上最明显的差异——硬件和代码都只需要实现一份。轮函数是X[i+4] = X[i] ^ T(X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i]),其中 T 变换 = S 盒代换 + 线性变换 L(循环左移 2/10/18/24 位后异或)。密钥扩展把 128 bit 主密钥异或 FK 常量后,用同一套结构但换成 L'(循环左移 13/23 位)生成 32 个轮密钥,CK 是固定常数表。
关键点在这里:解密不需要另写一套密钥扩展,只要把加密轮密钥倒序使用。所以包内如果同时给了sm4_setkey_enc和sm4_setkey_dec,后者大概率就是把轮密钥数组反转一遍。很多自实现版本在这块翻车——加密密钥扩展用 L',解密时误用 L,结果前几字节对得上、后面全乱。S 盒是 256 字节的表,查表实现约 1KB 常量 + 几 KB 代码,塞进小容量 MCU 完全够用;如果对侧信道有要求,就得换成位切片或布尔掩码实现,这是另一个话题。
2.3 SM2 不是“换个曲线的 ECDSA”,三处公式和流程都不一样
SM2 基于 256 bit 素域椭圆曲线,推荐曲线参数 a、b、G、n 都是固定的,不要自己生成曲线,对侧一定用的是推荐参数。
签名流程里,先算ZA = SM3(ENTL || ID || a || b || xG || yG || xA || yA),ENTL 是 ID 的比特长度(16 字节 ID 就是 0x0080),然后再算e = SM3(ZA || M)。这一步是把用户身份和曲线参数绑进摘要,属于 SM2 独有的设计——RSA 和 ECDSA 都没有。签名值r = (e + x1) mod n,s = ((1 + d)^-1 * (k - r*d)) mod n;验签时t = (r + s) mod n,算sG + tP得到点,再判断(e + x1') mod n == r。
加密是 ECIES 那一套:C1 = kG,共享点kP = (x2, y2),t = KDF(x2 || y2, klen),C2 = M ^ t,C3 = SM3(x2 || M || y2)。KDF 是 SM3 的计数器模式,每次喂x2 || y2 || ct,ct 从 1 开始递增。
2.4 包内模块的对应关系与编译方式
资源里的组织方式基本是每个算法一个.c/.h,加一个test/放已知答案向量,一个demo/放调用示例:
| 模块 | 典型文件 | 对外接口 | 依赖 | 主要用途 |
|---|---|---|---|---|
| SM3 | sm3.c / sm3.h | sm3_init / sm3_update / sm3_final | 无 | 摘要、HMAC、KDF、ZA |
| SM4 | sm4.c / sm4.h | sm4_setkey_enc / sm4_setkey_dec / sm4_crypt_ecb | 无 | 字段加密、报文加密(需外套模式) |
| SM2 | sm2.c / sm2.h(含大数与点运算) | sm2_keygen / sm2_sign / sm2_verify / sm2_encrypt / sm2_decrypt | 内部调用 SM3 | 签名验签、密钥交换、小数据加密 |
注意这张表最后一行:SM2 不是独立模块,它算 ZA 和 KDF 都要用 SM3,所以三个.c必须一起链进去,不能只挑 SM2 一个。先确认它没有第三方头文件依赖:
# 1. 只编译不链接,确认头文件依赖干净 gcc -std=c99 -O2 -Wall -Wextra -Iinclude -c src/sm3.c src/sm4.c src/sm2.c ar rcs libgm.a sm3.o sm4.o sm2.o # 2. 跑包内自带的已知答案测试,全绿再往下接 gcc -std=c99 -O2 -Iinclude test/test_sm3.c libgm.a -o t_sm3 && ./t_sm3 gcc -std=c99 -O2 -Iinclude test/test_sm4.c libgm.a -o t_sm4 && ./t_sm4 gcc -std=c99 -O2 -Iinclude test/test_sm2.c libgm.a -o t_sm2 && ./t_sm2-std=c99是因为代码里会用到stdint.h的定宽类型和 for 循环内声明;-Wall -Wextra建议一定加上,这类密码实现里“隐式截断”“有符号右移”都是真实存在的隐患,我就在-Wextra的告警里抓到过一次 64 位中间结果被塞进 32 位变量的写法。ar rcs生成静态库,方便直接嵌进已有构建系统。交叉编译到 ARM 时把gcc换成对应的工具链即可,代码本身没有平台相关部分。
提示:如果目标平台是大端架构,重点看 SM2 里大数序列化和 SM4 的 32 位字读取——SM4 通常按字节拼 32 位字,字节序无关;SM2 的 r、s 按大端编码,和平台字节序相反时要做显式转换。
3. 先把 SM3 和 SM4 跑通:三种调用姿势与分组模式的两个决定
对称和摘要这两块没有密钥协商的复杂度,最适合用来验证“包本身是不是好的”。这一步跑通,后面 SM2 出问题就可以放心地把锅甩给参数不对齐,而不是怀疑底层实现。
3.1 SM3 的三种调用姿势:一次性、流式、文件
最简单的是一次性摘要,输入输出都在手边、长度可控的场景直接用。流式则用于文件、socket、大报文,update的入参长度不需要是 64 的整数倍,模块内部自己攒块:
#include <stdio.h> #include <string.h> #include "sm3.h" /* 流式摘要:文件、socket 都按这个套路,块边界由 update 内部处理 */ static int sm3_file(const char *path, uint8_t out[32]) { FILE *fp = fopen(path, "rb"); if (!fp) return -1; sm3_ctx ctx; uint8_t buf[4096]; size_t n; sm3_init(&ctx); while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { /* 入参长度任意,不要求是 64 的整数倍 */ sm3_update(&ctx, buf, n); } /* 内部补 0x80、补零到 56 mod 64、写入 8 字节比特长度 */ sm3_final(&ctx, out); fclose(fp); return 0; } int main(void) { uint8_t dgst[32]; sm3_ctx ctx; /* 用例 1:一次性摘要,"abc" 的期望值见下方注释 */ sm3_init(&ctx); sm3_update(&ctx, (const uint8_t *)"abc", 3); sm3_final(&ctx, dgst); /* 期望:66c7f0f4 62eeedd9 d1f2d46b dc10e4e2 4167c487 5cf2f7a2 297da02b 8f4ba8e0 */ for (int i = 0; i < 32; i++) printf("%02x", dgst[i]); putchar('\n'); /* 用例 2:同一份数据拆成三次 update,结果必须完全一致 */ sm3_init(&ctx); sm3_update(&ctx, (const uint8_t *)"a", 1); sm3_update(&ctx, (const uint8_t *)"b", 1); sm3_update(&ctx, (const uint8_t *)"c", 1); sm3_final(&ctx, dgst); for (int i = 0; i < 32; i++) printf("%02x", dgst[i]); putchar('\n'); return 0; }这段代码干了三件事。第一,验证压缩函数和填充规则对不对——"abc"只有 3 字节,会走完整的补 0x80、补零、写比特长度这条路,是最容易暴露填充 bug 的用例。第二,验证分块状态机:把同一份数据拆成 1+1+1 三次update,结果必须和一次性喂进去完全一致,这是判断 ctx 里缓冲区管理写得对不对的最快办法。第三,sm3_file那一段是给生产用的,4KB 的读缓冲对磁盘和网络都合适,别用 64 字节去一次一次读,IO 次数会拖垮整体吞吐。
注意:
sm3_final之后 ctx 里是脏数据,同一个 ctx 要再算一次必须重新sm3_init,不要图省事复用。有些实现会在 final 里把 ctx 清零,有些不清理,行为不一致,别依赖。
3.2 SM4 的分组模式选择:ECB 只配做自测
SM4 一次只处理 16 字节。ECB 模式的问题是相同明文块产生相同密文块,一张表里的重复字段会直接暴露分布特征,所以 ECB只用于跑已知答案测试,业务数据必须套 CBC 或 CTR。
CBC 需要两个决定:IV 怎么来、填充怎么做。IV 每次加密都要重新随机生成,随密文一起传,绝不能固定;填充用 PKCS#7,末块不足 16 字节补pad个pad值,正好整除时补满 16 字节——因为解密端要靠最后一字节判断去掉多少,补 0 会导致原始末尾的 0x00 被吃掉。
#include <string.h> #include "sm4.h" /* CBC 加密:iv 每次随机生成,随密文一起下发;PKCS#7 填充 */ int sm4_cbc_encrypt(const uint8_t key[16], const uint8_t iv_in[16], const uint8_t *in, size_t len, uint8_t *out, size_t *out_len) { sm4_key ks; uint8_t iv[16]; memcpy(iv, iv_in, 16); sm4_setkey_enc(&ks, key); size_t pad = 16 - (len % 16); /* 整除时补满 16 字节,否则解密无法还原 */ size_t total = len + pad; for (size_t i = 0; i < total; i += 16) { uint8_t blk[16]; for (int j = 0; j < 16; j++) { size_t idx = i + j; blk[j] = (idx < len) ? in[idx] : (uint8_t)pad; } for (int j = 0; j < 16; j++) blk[j] ^= iv[j]; /* 先与前一块密文异或 */ sm4_crypt_ecb(&ks, blk, out + i); /* 单块原语 */ memcpy(iv, out + i, 16); /* 链值更新为当前密文块 */ } *out_len = total; return 0; }逻辑上就是把 ECB 当单块原语用,外面套一层异或链。三个参数要留意:key是 16 字节,业务上不要直接用口令字符串,先过一次 SM3 取前 16 字节或者走 KDF;iv_in必须来自密码学安全的随机源,用时间戳或rand()生成 IV 等于没加密;out的长度要按len + 16预留,调用方最容易在这里少分配 16 字节导致越界写。
单块测试用例的期望值可以对着表核:
| 用例 | 密钥 / 明文 | 期望密文 |
|---|---|---|
| SM4 单块 | key = pt = 0123456789abcdeffedcba9876543210 | 681edf34d206965e86b3e94f536e4246 |
| SM4 百万次迭代 | 同上,连续加密 1000000 次 | 595298c7c6fd271f0402f804c33d3f66 |
#include <stdio.h> #include <string.h> #include "sm4.h" int main(void) { const uint8_t key[16] = { 0x01,0x23,0x45,0x67,0x89,0xab,0xcd,0xef, 0xfe,0xdc,0xba,0x98,0x76,0x54,0x32,0x10 }; uint8_t pt[16] = { 0x01,0x23,0x45,0x67,0x89,0xab,0xcd,0xef, 0xfe,0xdc,0xba,0x98,0x76,0x54,0x32,0x10 }; uint8_t ct[16], back[16]; sm4_key ks; sm4_setkey_enc(&ks, key); sm4_crypt_ecb(&ks, pt, ct); /* 期望 681edf34 d206965e 86b3e94f 536e4246 */ /* 解密只需把轮密钥倒序,不需要另写轮函数 */ sm4_setkey_dec(&ks, key); sm4_crypt_ecb(&ks, ct, back); printf("roundtrip %s\n", memcmp(pt, back, 16) == 0 ? "ok" : "FAIL"); return 0; }百万次迭代那个向量值得单独跑一遍,它能把“每一轮的输出都错一点点”这类问题放大出来——单块测试有时碰巧能过,连续迭代几十万次就露馅了,通常是 S 盒或者循环移位位数写错。跑完这一组,SM4 基本可以放心用了。
3.3 把摘要和加密串起来:一个字段级加密的例子
单用 SM4 只能保机密性,改一位密文解密端是察觉不到的。常见的做法是“加密 + 摘要”:密文算一遍 SM3 作为校验值一起存,解密后重算对比。如果要做认证加密,建议直接上先加密后 HMAC 的组合(Encrypt-then-MAC),校验值覆盖 IV 和密文,别只覆盖明文。
/* 存储格式:iv(16) || ciphertext(n) || tag(32) * tag = SM3(iv || ciphertext),解密前先校验,校验失败直接拒绝 */ int seal_field(const uint8_t key[16], const uint8_t *plain, size_t plen, uint8_t *out, size_t *out_len) { uint8_t iv[16]; if (random_bytes(iv, 16) != 0) return -1; /* 必须是 CSPRNG */ size_t clen = 0; memcpy(out, iv, 16); sm4_cbc_encrypt(key, iv, plain, plen, out + 16, &clen); sm3_ctx ctx; sm3_init(&ctx); sm3_update(&ctx, out, 16 + clen); /* 覆盖 IV 和密文 */ sm3_final(&ctx, out + 16 + clen); *out_len = 16 + clen + 32; return 0; }这里的关键是校验范围包含 IV。只校验密文的话,攻击者改 IV 可以让解密端的第一块明文按可控方式偏移,而校验照样通过。random_bytes那一步一定接系统级随机源,不能图省事。
4. SM2 签名与加解密:ZA、补零和 C1C3C2 三处必须对齐
SM2 是这套包里唯一需要和对面系统“对参数”的模块。算法本身写错的概率不高,出问题基本都在协议层的约定上——双方用的 ID 不同、签名值的编码不同、密文的字段顺序不同,每一种都会表现为“验签失败”或“解密乱码”,但原因完全不同。
4.1 密钥对生成与私钥存储
私钥 d 是[1, n-2]区间内的随机数,公钥是点dG,输出时用未压缩格式04 || X || Y,共 65 字节。生成时依赖的是同一个 CSPRNG,不要用srand(time(NULL))这一类——SM2 的签名 k 一旦可预测,私钥就能被反推出来,这是这类算法最致命也最常见的一类失误。
私钥落盘要加密存储,通常的做法是用一个主密钥走 SM4 加密私钥字节,主密钥放配置中心或 KMS,不跟着代码走。资源包里如果不带存储方案,这部分要自己补,别把裸私钥写进配置文件。
4.2 签名验签:ID 必须双方一致,r/s 必须定长大端补零
调用接口时最常见的写法是把 ID 传进去,让模块内部先算 ZA 再算 e:
#include "sm2.h" /* 双方必须约定同一个 ID,国密推荐的缺省值是 16 字节 "1234567812345678" */ static const uint8_t USER_ID[] = "1234567812345678"; int demo_sign_verify(void) { sm2_key kp; if (sm2_keygen(&kp) != 0) return -1; /* d ∈ [1, n-2],公钥为 dG */ const uint8_t msg[] = "order=10086&amt=1.00"; uint8_t sig[64]; /* 裸签名:r(32) || s(32),大端定长 */ if (sm2_sign(&kp, USER_ID, sizeof(USER_ID) - 1, msg, sizeof(msg) - 1, sig) != 0) { return -1; } /* 验证侧必须拿到同一个公钥、同一个 ID,否则 ZA 不同,必失败 */ return sm2_verify(&kp.pub, USER_ID, sizeof(USER_ID) - 1, msg, sizeof(msg) - 1, sig); }sizeof(USER_ID) - 1是字符数组去尾部\0的常规写法,得到的 16 会作为 ENTL 的输入值(比特长度 0x0080)。三处细节值得记下来:一是 ID 换了 ZA 就换了,很多对接问题最后查出来是两边一个用缺省 ID、一个用业务自定义 ID;二是 r 和 s 各 32 字节定长,即使高位是 0 也要补齐,把大整数转字节数组时“去掉前导零”是经典翻车点,会导致签名长度在 70~72 字节之间飘;三是如果对方用的是 ASN.1 DER 编码(30 len 02 len r 02 len s),需要自己做裸签名和 DER 之间的互转,别指望长度一致。
4.3 加解密:字段顺序和 KDF 长度是两个硬约定
SM2 加密产出的三段是 C1(65 字节的点)、C2(与明文等长的密文)、C3(32 字节摘要)。字段拼接顺序有两套:新版标准是C1 || C3 || C2,早期实现不少是C1 || C2 || C3。这两套在解密端看起来都“像”合法数据,但拼错就是解不出来。
/* 与对端约定的顺序写死在适配层,不要散落在业务代码里 */ int sm2_encrypt_pack(const sm2_pub *pub, const uint8_t *msg, size_t mlen, uint8_t *out, size_t *out_len) { uint8_t c1[65], c2[512], c3[32]; if (mlen > sizeof(c2)) return -1; if (sm2_encrypt(pub, msg, mlen, c1, c2, c3) != 0) return -1; /* 新版顺序:C1 || C3 || C2 */ memcpy(out, c1, 65); memcpy(out + 65, c3, 32); memcpy(out + 97, c2, mlen); *out_len = 97 + mlen; return 0; }还有一层:KDF 输出的密钥流长度必须等于明文长度,有些实现为了凑整按 16 字节对齐生成再截断,遇到非 16 字节整数倍的明文就会解出错误尾部。SM2 加密也不适合大数据——椭圆曲线运算的吞吐决定了它只该用来加密会话密钥或短令牌,正文数据交给 SM4。
4.4 与外部系统对接前的对齐清单
对接前把这几项写成一张表,双方确认签字,比事后抓包猜要省时间得多:
| 对齐项 | 常见取值 | 不对齐的后果 |
|---|---|---|
| 用户 ID | 缺省 "1234567812345678" / 业务自定义 | 验签必然失败 |
| 签名值编码 | 裸 r||s(64B) / ASN.1 DER(70~72B) | 解析报错或验签失败 |
| 加密字段顺序 | C1||C3||C2 / C1||C2||C3 | 解密乱码 |
| 公钥格式 | 未压缩 04||X||Y / 压缩 02/03||X | 点解析失败 |
| 曲线参数 | 推荐参数(固定) | 无法互算 |
| 字符串编码 | UTF-8 / GBK | 中文摘要不一致 |
5. 对不上就是这几处:五类互通失败的排查顺序
排错最忌讳的是从头把代码读一遍。这类算法的失败模式高度集中,按“摘要 → 对称 → 公钥”的顺序,从最简单的往下查,通常十分钟内能定位。下面五条是我自己踩过的,按出现频率排序。
5.1 SM3 结果和在线工具对不上
现象:同一段字符串,本地算出来的 32 字节和对方给的摘要完全不同,但换成空串或很短的输入又能对上。
原因:九成是填充里的长度字段写错了。SM3 末尾 8 字节记录的是比特长度而不是字节长度,且是大端;另一个高发点是update内部攒块时,对“刚好 56 字节”“刚好 64 字节”这两种边界处理越界,短输入不会触发,长输入必然出错。
解决:用 56 字节、64 字节、65 字节三组输入各测一次,同时验证“一次性喂入”和“拆成多次 update”结果是否一致。三组里只要有一组不一致,就是分块状态机的问题,直接看 ctx 里缓冲区的剩余长度计算。
5.2 SM4 解密出来前半段对、后半段乱
现象:单块测试通过,CBC 解密第一块明文正确,从第二块开始全错。
原因:链值更新写错了。加密时异或的是前一块密文,解密时异或的也是前一块密文,但解密必须先把当前密文块存下来再解密,顺序颠倒就会用解出来的明文去异或。另一种可能是解密密钥扩展把 L' 用成了 L。
解决:单独打印每一轮的链值,和手算结果对比。另外确认sm4_setkey_dec的实现是不是简单反转轮密钥数组——如果它内部重新跑了一遍密钥扩展却没换 L',就会出现“第一块对、后面全错”的特征。
5.3 SM2 验签一直是 false
现象:签名接口返回成功,验签固定失败,换消息内容也一样。
原因:按概率从高到低:两边 ID 不一致导致 ZA 不同;公钥点用了压缩格式而验证端按未压缩解析;r 或 s 转字节时去掉了前导零,长度不足 32 字节;签名值用了 DER 编码但验证端按裸 64 字节读。
解决:先把 ZA 单独打印出来双方比对,这一步能排掉一半问题。ZA 一致后再看签名值的十六进制长度——固定 64 字节,不是就直接定位到编码环节。
5.4 SM2 解密报“点无效”或解出乱码
现象:密文传过去,解密端抛异常或者返回一堆随机字节。
原因:C1C3C2 顺序不一致;或者 C1 少带了 04 前缀,65 字节变成 64 字节;或者 Base64 传输时被换行符污染。
解决:抓一次原始字节流,数长度。97 + 明文长度是新版顺序的标准长度,对不上就先怀疑顺序和前缀。Base64 那层统一去掉换行再解码。
5.5 本地全绿,一上生产就间歇性失败
现象:测试环境跑几千次没问题,生产环境偶发验签失败或解密异常,重试有时又能过。
原因:随机数源在多线程下不安全。SM2 的签名 k 和加密 k 都来自 CSPRNG,如果多个线程共用同一个未加锁的伪随机状态,会生成重复的 k——重复 k 直接导致私钥可被推导,同时也会让部分签名校验异常。
解决:确认随机源是线程安全的(每线程独立实例或系统调用),并在压测里加一条“连续签名一万次,检查 k 无重复”的断言。
6. 把向量回归和性能基线固化下来:上线前我会跑的那一套
这套包最终能不能留在项目里,取决于两件事:升级或改编译选项后行为有没有漂移,以及它在目标硬件上的开销是否可接受。这两件事都能用脚本固化成流水线里的一步,不用靠人记。
第一件事是已知答案回归。把标准向量整理成一张表,用脚本自动比对,失败直接阻断构建:
#!/usr/bin/env bash # 每次改代码或换编译器都跑一遍,任何一条不一致就退出非零 set -euo pipefail run_case() { local name="$1" expect="$2" actual actual="$(./build/gm_kat "$name")" if [ "$actual" != "$expect" ]; then echo "FAIL $name"; echo " expect: $expect"; echo " actual: $actual" exit 1 fi echo "ok $name" } run_case sm3_abc 66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0 run_case sm3_abcd16 debe9ff92275b8a138604889c18e5a4d6fdb70e5387e5765293dcba39c0c573 run_case sm4_block 681edf34d206965e86b3e94f536e4246 run_case sm4_million 595298c7c6fd271f0402f804c33d3f66gm_kat是个几十行的小程序,按名字分发到对应的用例并打印十六进制串。set -euo pipefail保证任何一步失败都立即中断,不会被后续输出掩盖。这张表的价值在于:它同时覆盖了短输入(走完整填充)、长输入(多块迭代)和高轮次累积误差,任何一次重构只要碰了 S 盒、循环移位位数或者填充逻辑,都会被立刻抓到。
第二件事是性能基线。我不太关心绝对值,关心的是“换优化等级”“换交叉编译工具链”“打开某个宏”之后有没有明显退化,所以只需要两个数:
# SM4 吞吐:1MB 数据,CBC 模式,跑 100 次取平均 ./build/gm_bench sm4_cbc 1048576 100 # SM2 单次操作耗时:签名 / 验签 / 加密 / 解密各 200 次 ./build/gm_bench sm2_sign 200 ./build/gm_bench sm2_verify 200把这两个输出记进版本库的一个文本文件里,跟代码一起走。基线的量级大致是:SM4 在普通 x86 上用表驱动实现能到每字节几十个时钟周期以内,SM2 一次签名在毫秒级、验签因为要做双点乘通常是签名的两倍左右;具体数字依平台和实现差异很大,所以我不写死阈值,只看相对变化——同一台机器上前后的比值超过 20% 就值得看一眼这次改了什么。
还有一条经验是关于侧信道的。表驱动 SM4 的 S 盒查表会带来缓存时序差异,如果这套系统跑在多租户环境或者有远程计时攻击面,就得考虑位切片实现,代价是代码复杂度上升、吞吐通常还会降一截。SM2 的点运算里,标量乘是否做了常数时间处理也是一个检查项——资源包里的实现如果没做,就别把它放到攻击者能反复调用并测量时间的接口上。
最后是我自己的一个习惯:拿到任何一份密码学源码,不管作者是谁、注释写得多清楚,我都会先用那四个向量跑一遍,再把-Wall -Wextra打开重新编一次,看看有没有告警。这个动作花不了五分钟,但它拦下过两次“看起来能跑、实际在某类输入下静默出错”的版本。后来这套向量表就固化在流水线里,成了改代码之后的第一道闸。希望帮到你。
本文还有配套的精品资源,点击获取