4、SM4加解密流程:Feistel网络结构、轮函数F详解、加解密过程对比

好,咱们进入正题。SM4的加解密流程,说白了就是一套精心设计的轮转结构。我当年第一次看国标文档时,说实话也被那一堆符号绕得有点晕。但后来自己动手实现了一遍,发现核心逻辑其实很清晰——就是Feistel网络加上一个精巧的轮函数F。

4.1 Feistel网络结构:SM4的骨架

SM4采用的是典型的平衡Feistel网络结构。什么叫平衡?就是每次把128位数据块分成左右两半,各64位,然后一轮一轮地“搅和”。

我个人习惯把Feistel网络想象成一个“拧毛巾”的过程:左边拧到右边,右边拧到左边,反复拧,最后水(明文特征)就均匀分布了。

核心公式:

第i轮:Xi+1 = (Xi-1 ⊕ F(Xi, RKi)) || Xi

其中Xi-1是左半部分,Xi是右半部分,RKi是第i轮轮密钥。

嗯,这里要注意:SM4的Feistel结构和DES有个明显区别——它的左右两半在每一轮都会交换位置,但解密时不需要像DES那样反过来用轮密钥顺序。为什么?因为SM4的轮函数F设计得足够对称,解密就是加密的逆过程,轮密钥倒着用就行。

我曾经在给一个嵌入式设备移植SM4时,发现硬件加速器只实现了加密路径,解密得靠软件模拟。当时我就利用Feistel网络的可逆性,直接把加密函数当解密用,省了一笔硬件改版费用。这个技巧,你后面做工程优化时可能会用到。

4.2 轮函数F详解:SM4的心脏

轮函数F是整个SM4算法最核心的部分。它接收两个输入:右半部分64位数据和当前轮密钥32位,输出32位数据。结构上分为三步:

  1. 异或加轮密钥:把32位输入和32位轮密钥做异或
  2. S盒非线性变换:把32位结果分成4个8位,分别查S盒
  3. 线性变换L:对S盒输出做循环移位和异或组合

说白了,F函数 = L(S(X ⊕ RK))。就这么简单。

避坑指南:我曾经在实现S盒查表时,把S盒的索引和输出搞反了。SM4的S盒是8进8出,输入0x00输出0xD6。如果你发现加解密结果全是乱的,先检查S盒对不对。

线性变换L的公式如下:

L(B) = B ⊕ (B <<< 2) ⊕ (B <<< 10) ⊕ (B <<< 18) ⊕ (B <<< 24)

这里的<<<表示循环左移。你想想看,这个L变换其实就是在32位空间里做扩散,让一个比特的变化能影响到尽可能多的比特位。我刚开始学的时候,觉得这公式是拍脑袋想出来的。后来做差分分析才发现,这个移位组合是经过精心设计的——它能保证4轮就能达到完全扩散。

4.3 加解密过程对比:一张表说清楚

SM4的加解密过程,本质上是一模一样的操作,唯一的区别是轮密钥的使用顺序。加密时从RK0用到RK31,解密时从RK31用到RK0。

步骤 加密 解密
输入 128位明文 + 32位轮密钥RK0~RK31 128位密文 + 32位轮密钥RK31~RK0
轮数 32轮 32轮
轮函数 F(Xi, RKi) F(Xi, RK31-i)
输出变换 最后一轮后做反序变换R 最后一轮后做反序变换R
结果 128位密文 128位明文

这里有个细节很多人会忽略:最后一轮做完32轮迭代后,还需要做一个反序变换R。R就是把(X32, X33, X34, X35)这四个32位字倒序输出为(X35, X34, X33, X32)。

为什么要有这个反序?因为Feistel网络最后一轮输出时,左右两半没有交换。如果不做反序,解密时就没法直接用同样的结构。这个设计,说白了就是为了让加解密代码能复用同一套逻辑。

注意:SM4的加解密硬件实现时,轮密钥生成和加解密可以流水线并行。但如果你用软件实现,记得把轮密钥提前算好存起来,别每轮都重新算一遍——我见过有人这么干,性能直接掉了30%。

4.4 加解密流程对比图

下面这张图把加密和解密的流程放在一起对比,你一眼就能看出它们的对称性。

SM4加解密流程对比图 加密流程 128位明文输入 32轮Feistel迭代 反序变换R 128位密文输出 解密流程 128位密文输入 32轮Feistel迭代 反序变换R 128位明文输出 轮密钥顺序相反 轮函数F完全相同 关键结论 1. 加解密使用完全相同的轮函数F,硬件实现可复用 2. 唯一区别:加密用RK0→RK31,解密用RK31→RK0 3. 反序变换R保证加解密结构完全对称

4.5 加解密代码实现对比

下面这段代码展示了SM4加解密的核心逻辑。你会发现,加密和解密函数几乎一模一样,唯一的区别就是轮密钥数组的遍历方向。

// SM4加密核心函数
void sm4_encrypt(uint32_t plaintext[4], uint32_t rk[32], uint32_t ciphertext[4]) {
    uint32_t x[36];
    int i;
    
    // 初始赋值
    for (i = 0; i < 4; i++) x[i] = plaintext[i];
    
    // 32轮迭代,轮密钥正向使用
    for (i = 0; i < 32; i++) {
        x[i+4] = x[i] ^ F(x[i+1] ^ x[i+2] ^ x[i+3] ^ rk[i]);
    }
    
    // 反序变换R
    ciphertext[0] = x[35];
    ciphertext[1] = x[34];
    ciphertext[2] = x[33];
    ciphertext[3] = x[32];
}

// SM4解密核心函数
void sm4_decrypt(uint32_t ciphertext[4], uint32_t rk[32], uint32_t plaintext[4]) {
    uint32_t x[36];
    int i;
    
    // 初始赋值
    for (i = 0; i < 4; i++) x[i] = ciphertext[i];
    
    // 32轮迭代,轮密钥反向使用
    for (i = 0; i < 32; i++) {
        x[i+4] = x[i] ^ F(x[i+1] ^ x[i+2] ^ x[i+3] ^ rk[31 - i]);
    }
    
    // 反序变换R
    plaintext[0] = x[35];
    plaintext[1] = x[34];
    plaintext[2] = x[33];
    plaintext[3] = x[32];
}

工程经验:我建议你在实现时,把加密和解密合并成一个函数,通过一个方向参数来控制轮密钥的遍历顺序。这样代码量减半,也更容易维护。我在一个IoT项目里就是这么干的,固件体积直接少了2KB。