1. 为什么要自己撸一套国密SM算法
前阵子有个朋友做项目国产化适配,问我C++里怎么用国密SM算法。我第一反应是“直接拉一个OpenSSL新版不就有SM2/SM3/SM4了么”,结果他说第三方库不好过审计,而且领导希望底层密码模块完全自主可控。我一琢磨,干脆带着他手搓一遍,结果搓完发现这事儿没想象中那么玄,但坑也确实不少。
这篇文章就围绕一个核心目标展开:在完全不依赖任何第三方密码库的前提下,用C++从零实现国密SM2、SM3、SM4。对,不是封装接口,不是调用现成库,而是从大数运算、S盒、椭圆曲线点乘开始一层层写。适合三类人看:一是被国产化改造折磨的C++开发,二是想通过具体项目搞懂密码学原理的学生,三是对“自己写密码代码”这件事有好奇心、想验证自己实力的技术人。
先泼一盆冷水:写完之后你大概率不会立刻变成什么“加密大神”,但你会对字节序、模运算、常数时间这些概念产生肌肉记忆。更重要的是,这一套代码真的能跑通签名、验签、加密、解密、哈希,这就是硬通货。
1.1 国密SM家族一次认清
国密SM系列其实是一个算法族,最常用的就是三个:
- SM2:椭圆曲线公钥密码算法,管签名、验签、密钥交换、加密。功能上和ECDSA、RSA类似,但基于椭圆曲线,256位曲线。
- SM3:密码杂凑算法,输出256位摘要。功能上类似SHA-256。
- SM4:分组密码算法,分组长度128位、密钥长度128位,管数据加密。功能上类似AES。
大部分业务场景只要把这仨组合起来就够了:SM3算摘要,SM4加密数据,SM2做数字签名和密钥协商。后面的内容也按这个顺序推进,先写最好上手的SM3热身,再写SM4,最后啃SM2这块硬骨头。
1.2 不依赖第三方库,到底图什么
有人可能会问:OpenSSL里明明直接有国密实现,雷打不动又经过验证,为什么要自己写?我的看法是,从工程角度看,手写密码库的收益并不在于“造一个更好的轮子”,而在于三点:
一是审计可控。密码模块在很多项目里属于核心组件,第三方库的引入会带来供应链审计问题,你以为只依赖一个库,但它的编译链、依赖链、构建脚本全都是算法实现的一部分。自己写,代码量摆在那里,每一行都能看懂。
二是裁剪灵活。嵌入式设备、国产化平台往往只需要某个特定算法,为了一个SM4去引入几十兆的库亏得慌。自己实现可以只保留必需的代码。
三是学习价值。网上看一百遍公式不如亲手把SM3的压缩函数写对一次。写完之后你再看标准文档,很多抽象描述一下就有画面了。
代价也很明显:密码代码不能瞎写,很多坑(比如侧信道、随机数质量、时间泄漏)普通应用根本注意不到。所以我的建议是:学习目的随便写,但生产环境如果非要自研,务必请专业的密码算法审计人员过一遍,别拿用户数据开玩笑。
2. 第一步:先把256位大数运算写好
SM2跑在256位椭圆曲线上,所有运算都是大整数模素数p的运算。而C++原生类型最大也就64位,所以第一关不是算法本身,而是如何表示和计算256位整数。这一步写不好,后面全白搭。
2.1 大数的内存表示和加减法
表示大数最常见的方式是搞一个uint32_t数组,按小端序排列。为什么用32位而不是64位?因为做乘法进位时,两个32位数相乘结果是64位,正好用uint64_t装下,不会溢出,方便处理进位。如果用64位做数组元素,乘法结果会到128位,还得拆开处理,麻烦得多。
一个256位大数就是8个uint32_t:
struct BigInt256 { uint32_t v[8]; };加法就模拟手工竖式,从低位到高位逐“位”相加,遇到进位就往后传:
void add256(const uint32_t *a, const uint32_t *b, uint32_t *c) { uint64_t carry = 0; for (int i = 0; i < 8; ++i) { uint64_t s = (uint64_t)a[i] + b[i] + carry; c[i] = (uint32_t)s; carry = s >> 32; // 超过32位的部分就是进位 } }减法类似,只不过进位变成借位。这里有两个我踩过的细节:
- 进位判断不要用
if (s < a[i])这种依赖直觉的写法,直接用64位加法,超过32位上界自然产生进位,逻辑更稳。 - 乘法里同样要习惯“32位乘法、64位累加”这个套路,后面写SM2点乘时会大量用到。
2.2 乘法与模运算
乘法是学校竖式,复杂度O(n^2),256位只有8个元素,完全够用。核心思路:对于a的每一位i、b的每一位j,把乘积加到结果的第i+j和第i+j+1个元素上。注意要处理连续的进位,不能简单赋值,要用累加方式:
void mul256(const uint32_t *a, const uint32_t *b, uint32_t *c) { uint32_t tmp[16] = {0}; for (int i = 0; i < 8; ++i) { uint64_t carry = 0; for (int j = 0; j < 8; ++j) { uint64_t cur = (uint64_t)a[i] * b[j] + tmp[i + j] + carry; tmp[i + j] = (uint32_t)cur; carry = cur >> 32; } tmp[i + 8] += (uint32_t)carry; // 注意这里也可能再次产生进位,严格时要循环处理 } for (int i = 0; i < 16; ++i) c[i] = tmp[i]; }模运算有两类做法。如果只是临时验证,可以把乘出来的512位结果反复减模数p,直到结果小于p,这种“小学生取模”在256位数量级上慢得感人,但正确性好,适合先跑通。要想实用,就得用蒙哥马利约简或者Barrett约简。蒙哥马利约简的思路是把除法变成一系列乘法和移位,代价是提前把数转成蒙哥马利域,算法细节多,不建议一开始就上。
我实际操作时的策略是:第一步先用最笨的“大数除法求余”把整个流程跑通,第二步再针对SM2热路径(点乘)替换成蒙哥马利实现。这样既保证正确性,又能看到性能差异,心里有数。
2.3 模逆元与扩展欧几里得
椭圆曲线点加、点乘运算里几乎每一步都要算“除以一个数”,而在模p的世界里除法就是乘上逆元。算模逆元最经典的办法是扩展欧几里得算法,它能求出满足a * x ≡ 1 (mod p)的x。
递归写法容易爆栈,我习惯用迭代版:
// 返回 x,使得 a * x % mod == 1,要求 a、mod 互素 int64_t inv_mod(int64_t a, int64_t mod) { int64_t t = 0, newt = 1; int64_t r = mod, newr = a; while (newr != 0) { int64_t q = r / newr; int64_t tmp = t - q * newt; t = newt; newt = tmp; tmp = r - q * newr; r = newr; newr = tmp; } if (r > 1) return -1; // 不存在逆元 if (t < 0) t += mod; return t; }注意算完的逆元有可能是负数,最后一定要加mod调整成正数。这个“调正数”的细节,我至少漏了三次,每次都是跑到SM2验签阶段发现结果对不上,回头查全是负数取模的锅。在C++里负数的%运算符结果是负的,跟数学上的模运算不一样,写密码代码时所有取模都要自己写一个函数保证结果落在[0, mod)区间。
3. 热身运动:手写SM3哈希
SM3是最适合用来找感觉的算法,它不像SM2有那么多数学背景,也不像SM4要处理S盒和分组模式,就是纯逻辑运算:异或、移位、模加、布尔函数。只要照着标准一步步来,很快能跑通。
3.1 填充规则与消息扩展
SM3先把任意长度的消息填充到512位的整数倍,规则和SHA-256很像:
- 消息后面先补一个
0x80字节。 - 继续补
0x00,直到长度模512等于448位。 - 最后拼上64位的比特长度(不是字节长度),大端序。
这个填充有个经典坑:如果你处理的是字节流,最后拼长度时要乘以8,转成比特数。我见过有人直接拼字节数导致结果全错,而且错得很隐蔽,因为填充后的分组依然合法,只是不是标准要求的填充。写一个小函数查填充后的长度是否和标准一致,比肉眼检查靠谱。
填充后的每个512位分组,要扩展成132个32位字:W[0]到W[67]加上W'[0]到W'[63]。前16个字就是消息分组本身,后面的按照递推公式生成:
W[j] = P1(W[j-16] ^ W[j-9] ^ (W[j-3] <<< 15)) ^ (W[j-13] <<< 7) ^ W[j-6]这里<<<是32位循环左移。P1函数定义为:
P1(X) = X ^ (X <<< 15) ^ (X <<< 23)W'[j]更简单:W'[j] = W[j] ^ W[j+4]。
第一次写的时候我总把<<<当成普通左移,忘了循环移位要分“移出的高位补到低位”两步。这个问题在SM3里还只是结果错,在SM4轮函数里会直接导致程序混乱,所以务必一开始就封装一个rotl32(x, n)函数。
3.2 压缩函数的64轮迭代
SM3的压缩核心是8个32位寄存器A、B、C、D、E、F、G、H,初始值IV是标准写死的:
7380166F 4914B2B9 172442D7 DA8A0600 A96F30BC 163138AA E38DEE4D B0FB0E4E每一轮做这样的操作:
- 计算SS1、SS2、TT1、TT2四个中间值
- 依次更新A到H
用代码表示核心循环:
for (int j = 0; j < 64; ++j) { uint32_t Tj = (j < 16) ? 0x79CC4519 : 0x7A879D8A; uint32_t SS1 = rotl32(rotl32(A, 12) + E + rotl32(Tj, j), 7); uint32_t SS2 = SS1 ^ rotl32(A, 12); uint32_t TT1 = FF(A, B, C, j) + D + SS2 + Wp[j]; uint32_t TT2 = GG(E, F, G, j) + H + SS1 + W[j]; D = C; C = rotl32(B, 9); B = A; A = TT1; H = G; G = rotl32(F, 19); F = E; E = P0(TT2); }其中FF和GG根据轮数选择不同布尔函数,前16轮和后48轮不一样,别漏。P0是另一个置换函数:
P0(X) = X ^ (X <<< 9) ^ (X <<< 17)最后把这一轮的A到H和上一轮的状态异或,得到新的中间状态。64轮跑完,8个寄存器的值按顺序拼起来就是256位摘要。
3.3 SM3实操验证
写完之后不要急着写SM4,先验证SM3。最可靠的验证方式是拿标准测试向量跑一遍,最经典的就是空字符串和abc:
SM3("")应该等于1AB21D8355CFA17F8E61194831E81A8F,后面还有一段。SM3("abc")应该等于66C7F0F462EEEDD9D1F2D46BDC10E4E2 4167C4875CF2F7A2297DA02B8F4BA8E0
我当时的做法是写一段测试代码,把摘要逐字节打印成十六进制,肉眼和标准值对比。这个办法虽然原始,但很有效。如果结果不对,建议在压缩函数的第0轮、第1轮、第15轮各打印一次A-H寄存器的值,跟标准过程值对照,能快速定位是布尔函数错了、循环移位错了还是状态更新顺序错了。
4. 分组加密:SM4的轮函数与密钥扩展
SM3只是热身,SM4才是第一个真正有“工程感”的部分。它要处理S盒、32轮迭代、轮密钥扩展,还要考虑加密和解密之间的关系。写完SM4,你就理解什么叫“轮密钥逆序”。
4.1 SM4整体结构
SM4的分组长度是128位,密钥长度也是128位。它属于不平衡Feistel结构,每一轮拿4个32位字X0、X1、X2、X3,用一个轮密钥rk更新其中一个字:
X[i+4] = X[i] ^ T(X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i])这个T函数是SM4的灵魂,它分成两步:
- 非线性变换τ:把32位输入拆成4个字节,每个字节过一次S盒。
- 线性变换L:把S盒输出的32位结果做循环移位异或:
L(B) = B ^ (B <<< 2) ^ (B <<< 10) ^ (B <<< 18) ^ (B <<< 24)全部32轮跑完之后,把X[32]到X[35]反序输出,得到密文。注意最后一定要做反序变换,很多人写到这里忘了把第4个字当第一个字输出,结果密文莫名奇妙地错位。
4.2 密钥扩展怎么生成
SM4的轮密钥rk[0]到rk[31]不是直接用主密钥,而是经过一个类似加密轮函数的扩展过程。
先定义4个系统参数FK:
FK[0] = A3B1BAC6 FK[1] = 56AA3350 FK[2] = 677D9197 FK[3] = B27022DC主密钥MK拆成4个32位字,和FK异或得到K[0]到K[3],然后递推生成后面的K:
K[i+4] = K[i] ^ T'(K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i])这里的T'和加密轮函数的T几乎一样,唯一的区别是线性变换L变成了L':
L'(B) = B ^ (B <<< 13) ^ (B <<< 23)CK是固定常数,一共32个,每个是32位字。这些常量不用手抄,可以现场生成:把每个CK[i]拆成4个字节,每个字节的值是(4 * i + j) * 7 mod 256,其中j是0到3的字节索引。我当时看到这个生成方式还挺惊喜,这意味着代码里不用摆一张32个常量的表,两句循环搞定,还能避免抄错。
4.3 解密为什么要逆序轮密钥
SM4的解密和加密共用同一个轮函数,唯一区别是解密时把轮密钥倒过来用:加密用rk[0]到rk[31],解密用rk[31]到rk[0]。
原因在于SM4的轮函数结构:解密过程其实就是把加密的最后一轮拆掉。加密最后一轮用的是rk[31],解密第一轮当然也要用rk[31],逐层逆推。这个设计让SM4的实现非常简洁,不需要写两套加解密逻辑。
实现时顺手写一个测试用例:随机生成一个128位明文和128位密钥,加密得到密文,再用同样的密钥和逆序轮密钥解密,看能不能还原出明文。别小看这个自测,SM4的S盒表如果有任何一个字节抄错,这个测试立刻会暴露。我建议S盒表用标准附录的十六进制文本,用脚本生成一个C数组,别手打,手打必然错。
5. 硬菜:SM2椭圆曲线签名与验签
SM2是目前手写国密算法里最复杂的一块,原因只有一个:它跑在椭圆曲线上,涉及大数的模运算、点的标量乘法、坐标转换、杂凑值预处理。但拆开看,其实也没那么可怕,骨架就是“大数运算 + 点运算 + 签名验签流程”。
5.1 推荐参数和椭圆曲线群
SM2用的是256位素曲线,参数是标准推荐的,直接用十六进制抄下来即可:
p = FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFF a = FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFC b = 28E9FA9E 9D9F5E34 4D5A9E4B CF6509A7 F39789F5 15AB8F92 DDBCBD41 4D940E93 n = FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF 7203DF6B 21C6052B 53BBF409 39D54123 Gx = 32C4AE2C 1F198119 5F990446 6A39C994 8FE30BBF F2660BE1 715A4589 334C74C7 Gy = BC3736A2 F4F6779C 59BDCEE3 6B692153 D0A9877C C62A4740 02DF32E5 2139F0A0这里的p是素数模数,a、b是曲线方程y^2 = x^3 + ax + b的系数,G是基点,n是基点G的阶。所有点运算都在模p的有限域上进行,坐标满足曲线方程的就是曲线上点。
把这些参数定义好后,建议立刻写一个“点是否在曲线上”的校验函数:把点的x、y代入曲线方程,看左右两边是否相等。这一步能筛掉大量坐标运算的符号错误。
5.2 点加、点乘的实现
SM2的核心运算是点乘:kG表示把基点G自己加自己k次。点乘不是真的循环加k次,而是用“倍点”和“加法”组合,类似快速幂的思路。
先写点加和倍点公式。对仿射坐标来说,点加P(x1,y1) + Q(x2,y2)且P != Q时:
λ = (y2 - y1) / (x2 - x1) x3 = λ^2 - x1 - x2 y3 = λ * (x1 - x3) - y1倍点2P时:
λ = (3 * x1^2 + a) / (2 * y1) x3 = λ^2 - 2 * x1 y3 = λ * (x1 - x3) - y1这里“除以”就是乘以模逆元,所有减法、加法都要模p,结果要落在[0, p)区间。我建议把“模加、模减、模乘、模逆”封装成四个函数,所有点运算只调这四个函数,不要随手写% p,否则负数取模会让你怀疑人生。
点乘就用最简单的从左到右二进制展开:
Point scalar_mul(const Point& P, const BigInt256& k) { Point R = {0, 0}; // 无穷远点,特殊处理 for (int i = 255; i >= 0; --i) { R = double_point(R); if (get_bit(k, i)) R = add_point(R, P); } return R; }仿射坐标实现虽然每步都要求逆元,慢,但正确性最好。等全部流程跑通后,想优化可以用雅可比坐标,那里不用求逆,速度能翻好几倍。我当时是先仿射跑通,再切到雅可比,前后对比性能差距大约有5倍。
5.3 预处理:ZA、消息摘要与KDF
SM2签名不是直接对消息摘要签名,而是要先算一个ZA值,再跟消息拼起来做SM3摘要。ZA的定义是:
ZA = SM3(ENTLA || IDA || a || b || Gx || Gy)其中IDA是用户身份标识,ENTLA是IDA的比特长度,占两个字节,a、b、Gx、Gy都是SM2推荐参数的十六进制表示。这个ZA计算很容易被忽略,但验签方如果和你用的IDA不一致,签名必然验不过。工程上常见做法是默认一个固定的IDA,像标准文档里给的那个样例,两边统一。
计算待签名摘要:
e = SM3(ZA || M)这里的M是原始消息字节流,不是消息的哈希。很多人默认“签名就是对消息摘要做运算”,直接签名SM3(M),结果和标准不兼容。正确流程是ZA和M拼接后再SM3。
SM2加密和密钥交换里还会用到KDF(密钥派生函数)。KDF的套路是用SM3不断哈希一个输入,再拼接一个32位计数器,从1开始递增,直到生成的字节数满足需求:
void kdf(const uint8_t *z, size_t zlen, size_t klen, uint8_t *out) { uint32_t ct = 1; size_t pos = 0; while (pos < klen) { // 哈希 Z || CT(CT为32位大端) // 把SM3的32字节结果拷到out,注意截断最后一段 ct++; } }KDF的长度参数是比特长度,不是字节长度,注意别搞混。
5.4 签名与验签完整流程
SM2签名算法基于ECDSA的变体。假设签名者的私钥是dA,公钥是PA = dA * G,对消息M签名:
- 计算
e = SM3(ZA || M)。 - 随机生成一个
k,范围在[1, n-1]。 - 计算点
(x1, y1) = k * G。 - 计算
r = (e + x1) mod n,如果r等于0或r + k等于n,重新选k。 - 计算
s = ((1 + dA)^-1 * (k - r * dA)) mod n,如果s等于0,重新选k。
输出签名(r, s)。注意第5步里有个(1 + dA)^-1的模逆运算,这是在模n下算的,不是在模p下。大数运算的模数一变,很多人就蒙了,签名验签都分不清哪些运算该用p、哪些该用n。这个必须时刻想清楚:点坐标的运算都用p,签名标量运算都用n。
验签过程如下:
- 验证
r、s都在[1, n-1]范围内,否则直接拒绝。 - 计算
t = (r + s) mod n,t等于0则拒绝。 - 计算点
(x1', y1') = s * G + t * PA。 - 计算
R = (e + x1') mod n。 - 判断
R == r,相等则验签通过。
验签里涉及一次双标量乘sG + tPA,如果从零开始写,最简单的方法就是分别算sG和tPA再点加,效率低但清晰。高级做法是联合的Shamir技巧一次算完,我建议先把前者跑通,再优化。
5.5 实现中那些烦人的转换问题
SM2的代码里有一半的bug不是算法逻辑错,而是数据格式错。几个常见的:
- 大整数和字节流之间的转换方向。曲线参数、签名结果r和s、点坐标x1,这些都要从大整数转成固定32字节的字节流,很多标准都明确要求大端序。你在内存里是小端数组,输出时就要反着拼。
- 哈希摘要e是256位,但参与模n运算时要转成大整数。如果n的最高位恰好是0(SM2的n最高字节是FFFFFFFE,没问题),但转换截断逻辑还是要写对。
- 点乘结果如果是无穷远点,需要特殊处理。虽然概率极低,但代码里不能没有这个分支,否则会以零坐标继续算,得到一堆乱七八糟的数。
6. 联调验证与排错
写完全部算法后,真正花时间的不是写,而是“如何证明你写对了”。这一步我深有体会,写SM3花了一天,调SM2的验签翻了三天车,最后发现只是字节序问题。
6.1 用标准测试向量验证
每一个算法都要有独立的测试向量,不能只靠自洽:
- SM3:用空字符串、
abc、长消息三个向量,不仅能对最终摘要,还能对中间哈希值。 - SM4:用标准里的明文、密钥、密文三元组,直接对密文。注意十六进制字符串转字节时的大小端处理。
- SM2:最稳妥的方法是先生成一个密钥对,自签名再自验签。自洽通过了再找标准文档里的签名示例逐字节核对。还有一个细节:SM2签名带随机数k,同样的消息每次签名结果不同,所以不能用“两次签名一致”来验证,只能靠验签是否通过。
我当时给SM2加了一个中间状态打印工具,把计算e时用的ZA、每次点乘后的坐标x1、模n后的r和s全部打印成十六进制,然后跟标准示例一步一步对。这个方法看起来很笨,但排查转换问题极其有效。
6.2 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| SM3摘要前几字节对、后面全错 | 寄存器状态更新顺序错,尤其C、D、G、H的轮换顺序 | 对照标准压缩函数逐行检查 |
| SM3长度填导致结果错 | 填的字节长度而不是比特长度,或者64位长度不是大端 | 检查填充函数,打印填充后的分组 |
| SM4加密后解密不还原 | 轮密钥顺序没有逆序,或者加密结果忘记做反序R | 检查rk的顺序和X[32]到X[35]的输出顺序 |
| SM4结果错但解密能还原 | 加解密用的是同一套逻辑,错也错得一致 | 用标准测试向量对密文输出 |
| SM2验签一直失败 | 大整数取模出现负数,或字节序转换反了 | 在模运算函数入口统一处理负数,打印r、s、e的十六进制 |
| SM2点乘偶尔算出无穷远点导致崩 | 标量k为0或n,随机数生成没卡范围 | 签名函数里判断k是否在[1, n-1] |
| 编译期S盒负索引 | 用了有符号char当数组下标,遇到大于127的字节变负数 | S盒下标统一转成uint8_t |
6.3 性能优化与个人体会
初版代码跑通后,性能通常比较感人。我最开始SM2签名一次要几百毫秒,瓶颈全在仿射坐标点乘——每加一次点都要算一次模逆,而扩展欧几里得算法本身又依赖模运算,套娃下来自然慢。
优化的顺序建议是:
- 编译器先开-O2。这行改动零成本,效果立竿见影。
- 大数乘法手写优化。避免不必要的函数调用,把乘加循环展开。
- 点乘切换到雅可比坐标。雅可比坐标下点加不需要求逆,只有最后转回仿射坐标时算一次逆元,性能提升最大。
- 预计算。如果公钥固定、消息多变,可以提前算好一些常用的标量乘结果,比如
2^t * G,能节省不少点乘时间。 - 签名随机数用安全随机源。这个不能省,随机数质量直接关系私钥安全,别为了性能用线性同余。
性能优化到“能用”的程度就行,我们手写的目标不是干翻OpenSSL,而是可控、可审计、可理解。真要拼性能,OpenSSL里有汇编级的优化,追不上的。认清这一点,代码写完心里也踏实。
最后再分享一点小经验
整套代码跑通那天,我干的第一件事不是庆祝,而是把之前调好的测试向量全重新跑了一遍,然后把工程里所有能统一封装的地方统一掉:大端读写、循环移位、模运算、十六进制打印,这些工具函数一旦写对,后续写SM2、SM4会省很多事。手写密码库最大的收获不是那几十个函数,而是你对字节序、模运算、状态更新顺序这堆“细碎细节”建立了本能反应。以后再看到“国密适配”需求,你脑子里浮现的就不是黑盒API,而是一张清晰的算法流程图,这种掌控感是调用第三方库永远给不了的。