3. SM4分组密码算法:从Feistel结构到工程落地
SM4,国密标准里的对称分组密码算法。说白了,它就是用来加密大量数据的——你发消息、存文件、做API通信,只要数据量大,基本都会用到它。
我记得刚接触SM4时,第一反应是“这不就是类AES的东西吗?”后来仔细一读标准才发现,它用的是Feistel结构,跟AES那种SPN结构完全是两码事。你想想看,Feistel结构有个天然好处:加密和解密可以用同一套逻辑,只是轮密钥顺序反过来。这在硬件实现上特别友好。
3.1 SM4算法原理:Feistel结构
SM4的分组大小是128位,密钥长度也是128位。它采用32轮Feistel结构,每轮处理32位数据。嗯,这里要注意:Feistel结构每轮只加密一半数据,另一半直接通过。
具体流程是这样的:
- 把128位明文分成4个32位字:
(X0, X1, X2, X3) - 每轮用轮密钥
rk_i对X1, X2, X3做一轮函数F,结果跟X0异或 - 然后整体左移一位:
(X1, X2, X3, X0 ⊕ F(X1⊕X2⊕X3⊕rk_i)) - 重复32轮,最后输出
(X32, X33, X34, X35)
轮函数F里包含三个操作:S盒替换、线性变换L、再跟轮密钥异或。S盒是固定的8×8查表,线性变换L是个32位的循环移位加异或组合。
核心要点:SM4的Feistel结构保证了加解密流程完全对称。解密时只要把32个轮密钥倒序使用,其他逻辑一字不改。这在嵌入式芯片里特别省资源。
我在项目中遇到过一个问题:有人把SM4的S盒实现成软件查表,结果在低端MCU上跑出性能瓶颈。后来改成硬件查表,吞吐量直接翻了5倍。所以,S盒的实现方式对性能影响很大。
3.2 SM4的密钥扩展与加解密流程
密钥扩展,就是把128位主密钥变成32个32位轮密钥。SM4的密钥扩展算法本身也用了Feistel结构,只是常数和S盒跟加密轮函数略有不同。
具体步骤:
- 主密钥
MK = (MK0, MK1, MK2, MK3) - 先跟系统参数FK异或:
K0 = MK0 ⊕ FK0,以此类推得到K1, K2, K3 - 然后从i=0到31,每轮生成一个轮密钥:
rk_i = K_{i+4} = K_i ⊕ T'(K_{i+1} ⊕ K_{i+2} ⊕ K_{i+3} ⊕ CK_i) - T'函数跟加密轮函数F类似,但线性变换L'是简化的版本
加解密流程就简单了:
- 加密:用
rk_0到rk_31顺序执行32轮Feistel - 解密:用
rk_31到rk_0逆序执行32轮Feistel
个人经验:我建议你在做密钥扩展时,把32个轮密钥一次性算好存起来。虽然SM4的密钥扩展不重,但每加密一块都重新算一遍,在高速场景下会浪费CPU周期。
我曾经在调试一个SM4硬件加速器时,发现解密结果总是不对。查了两天才意识到:密钥扩展时用的系统参数FK和常量CK,跟加密轮函数用的S盒虽然一样,但线性变换L'不同。这个坑,很多人第一次都会踩。
3.3 SM4的代码实现与模式(ECB/CBC)
代码实现这块,我直接给你一个核心的SM4加密轮函数和密钥扩展的C语言片段。嗯,这里只展示最关键的逻辑,完整工程代码建议参考GM/T 0002-2012标准附录。
// SM4 轮函数 F
uint32_t sm4_f(uint32_t x0, uint32_t x1, uint32_t x2, uint32_t x3, uint32_t rk) {
uint32_t t = x1 ^ x2 ^ x3 ^ rk;
// S盒替换
uint8_t b[4];
b[0] = SBOX[(t >> 24) & 0xFF];
b[1] = SBOX[(t >> 16) & 0xFF];
b[2] = SBOX[(t >> 8) & 0xFF];
b[3] = SBOX[t & 0xFF];
uint32_t z = (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3];
// 线性变换 L
return z ^ ROTL(z, 2) ^ ROTL(z, 10) ^ ROTL(z, 18) ^ ROTL(z, 24);
}
// 密钥扩展
void sm4_key_expand(uint32_t mk[4], uint32_t rk[32]) {
uint32_t k[36];
int i;
// 系统参数 FK
uint32_t FK[4] = {0xA3B1BAC6, 0x56AA3350, 0x677D9197, 0xB27022DC};
for (i = 0; i < 4; i++) k[i] = mk[i] ^ FK[i];
// 32轮生成轮密钥
for (i = 0; i < 32; i++) {
k[i+4] = k[i] ^ sm4_f_kexp(k[i+1], k[i+2], k[i+3], CK[i]);
rk[i] = k[i+4];
}
}
避坑指南:我曾经在移植SM4到ARM Cortex-M0平台时,发现SBOX查表用uint8_t数组没问题,但线性变换L里的循环移位如果写成函数调用,性能会暴跌。建议直接用宏或内联函数实现ROTL。
3.4 工作模式:ECB与CBC
SM4本身只定义了分组加密的核,怎么处理超过128位的数据,要靠工作模式。最常用的就是ECB和CBC。
| 模式 | 特点 | 适用场景 | 风险 |
|---|---|---|---|
| ECB | 每个分组独立加密,可并行 | 密钥加密、小数据量 | 相同明文块产生相同密文块,有模式泄露风险 |
| CBC | 每个分组依赖前一个密文块,需要IV | 文件加密、网络通信 | 不能并行,IV必须随机且不可重复 |
ECB模式,说白了就是拿SM4对每个128位分组独立加密。好处是简单、可并行,坏处是——你想想看,如果明文里有重复的块,密文也会重复。这在加密图片时特别明显,轮廓都能看出来。
CBC模式就好很多。每个明文块先跟前一个密文块异或,再加密。第一个块跟IV异或。这样即使明文有重复,密文也看不出规律。
个人建议:除非你明确知道自己在做什么(比如加密随机密钥),否则别用ECB。我在项目中见过有人用ECB加密数据库索引字段,结果被攻击者通过密文频率分析还原出部分数据。CBC虽然慢一点,但安全得多。
CBC模式实现时要注意IV的生成。我曾经踩过一个坑:用时间戳做IV,结果同一秒内加密的两个消息IV相同,攻击者直接通过密文比较推断出明文关系。正确的做法是用密码学安全的随机数生成器,每次加密都生成新的IV。
// SM4-CBC 加密示例(伪代码)
void sm4_cbc_encrypt(uint8_t *plain, uint8_t *cipher, uint32_t len,
uint8_t *key, uint8_t *iv) {
uint8_t block[16], prev[16];
memcpy(prev, iv, 16); // 初始IV
for (int i = 0; i < len; i += 16) {
// 明文块与前一个密文块异或
for (int j = 0; j < 16; j++)
block[j] = plain[i+j] ^ prev[j];
// SM4加密
sm4_encrypt_block(block, key);
memcpy(cipher+i, block, 16);
memcpy(prev, block, 16); // 更新前一个密文块
}
}
工程要点:SM4的CBC模式在解密时可以并行——因为解密只需要前一个密文块,不需要前一个明文块。但加密必须串行。如果你需要高吞吐量的加密,可以考虑CTR或GCM模式,它们支持完全并行。
好了,SM4的核心内容就这些。Feistel结构、密钥扩展、ECB/CBC模式,这三块搞明白,你就能在工程里正确使用SM4了。记住:算法本身只是工具,怎么用、用什么模式,才是决定安全性的关键。