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盒跟加密轮函数略有不同。

具体步骤:

  1. 主密钥MK = (MK0, MK1, MK2, MK3)
  2. 先跟系统参数FK异或:K0 = MK0 ⊕ FK0,以此类推得到K1, K2, K3
  3. 然后从i=0到31,每轮生成一个轮密钥:rk_i = K_{i+4} = K_i ⊕ T'(K_{i+1} ⊕ K_{i+2} ⊕ K_{i+3} ⊕ CK_i)
  4. T'函数跟加密轮函数F类似,但线性变换L'是简化的版本

加解密流程就简单了:

  • 加密:用rk_0到rk_31顺序执行32轮Feistel
  • 解密:用rk_31到rk_0逆序执行32轮Feistel

个人经验:我建议你在做密钥扩展时,把32个轮密钥一次性算好存起来。虽然SM4的密钥扩展不重,但每加密一块都重新算一遍,在高速场景下会浪费CPU周期。

我曾经在调试一个SM4硬件加速器时,发现解密结果总是不对。查了两天才意识到:密钥扩展时用的系统参数FK和常量CK,跟加密轮函数用的S盒虽然一样,但线性变换L'不同。这个坑,很多人第一次都会踩。

SM4 密钥扩展与加解密流程 主密钥 MK (128位) 系统参数 FK ⊕ K0~K3 (初始) 密钥扩展循环 (i=0..31) 常量 CK_i rk_0 ~ rk_31 明文 (128位) 32轮 Feistel 加密 (rk_0 → rk_31) 轮密钥 密文 (128位) 密文 (128位) 32轮 Feistel 解密 (rk_31 → rk_0) 明文 (128位) 说明: 密钥扩展使用系统参数FK和常量CK,生成32个轮密钥。 加密顺序使用rk_0→rk_31,解密逆序使用rk_31→rk_0,其他逻辑完全一致。

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了。记住:算法本身只是工具,怎么用、用什么模式,才是决定安全性的关键。