3. SM4 加密流程详解:密钥扩展算法(32 轮迭代)

讲 SM4 的加密,绕不开密钥扩展。说白了,就是把用户输入的 128 位原始密钥,通过一套固定规则,变成 32 个 32 位的轮密钥。每个轮密钥对应一轮迭代,加密和解密各用一次。我个人习惯把这一步叫做“密钥喂养”,因为后面 32 轮计算全靠它喂数据。

你可能会问:为什么不直接用原始密钥?嗯,原因很简单——扩散性。原始密钥只有 128 位,如果每一轮都用同一个子密钥,那加密结果和明文之间的关联就太线性了,容易被人用差分分析攻破。密钥扩展就是为了打破这种线性关系,让每一轮的密钥都不一样。

核心目标:将 128 位主密钥 MK 经过 32 轮非线性变换,生成 32 个 32 位轮密钥 rk[0]..rk[31]。每一轮轮密钥只在本轮中使用,顺序固定。

3.1 固定参数:系统参数 FK 与固定参数 CK

SM4 的密钥扩展用了两组固定常数。一组叫系统参数 FK,一组叫固定参数 CK。我刚开始接触的时候,总把这两组参数搞混,后来发现其实很好区分:FK 只参与第一轮初始化,CK 参与的是每一轮的迭代计算。

FK 的值(4 个 32 位常量)

符号 值(十六进制)
FK00xA3B1BAC6
FK10x56AA3350
FK20x677D9197
FK30xB27022DC

CK 的生成规则

固定参数 CK 一共有 32 个,每个都是 32 位。生成公式其实很简单:
CK_i = (4*i + 0)*0xFF + (4*i + 1)*0xFF00 + (4*i + 2)*0xFFFF00 + (4*i + 3)*0xFFFFFF00,说白了就是用 i 的序号算出一个 8 位循环值,再拼成 32 位。每个 CK 的取值在 SM4 标准文档里是列好的,实际做开发时直接查表就行,不用每次计算。

我的实操经验:在嵌入式环境中,我直接把这 32 个 CK 值预编译成一个 const 数组,省得运行时算。算一次和查一次的时间差在 MCU 上很明显。

3.2 密钥扩展算法流程(32 轮迭代)

好了,现在手上有主密钥 MK,加上 FK 和 CK,怎么生成 32 个轮密钥?步骤其实很清晰,我给你们撸一遍伪代码:

  1. 初始化:把 128 位 MK 拆成 4 个 32 位字 K0, K1, K2, K3;然后分别与 FK 异或:
    K0 ^= FK0, K1 ^= FK1, K2 ^= FK2, K3 ^= FK3。
    这一步相当于给原始密钥加了一层“白化”,避免 MK 直接暴露在迭代里。
  2. 32 轮迭代(i = 0 到 31):
    对每一轮,计算 X = K_{i+1} ^ K_{i+2} ^ K_{i+3} ^ CK_i;
    然后对 X 做非线性变换 τ(4 个 S 盒并行替换);
    再对 τ 的结果做线性变换 L'(循环左移 13、23 位后异或);
    最后 K_{i+4} = K_i ^ L'(τ(X))。
    注意!这里得到的 K4 就是第一个轮密钥 rk[0];K5 是 rk[1],依此类推。每次迭代只推出一个新轮密钥。
  3. 输出:rk[i] = K_{i+4},i 从 0 到 31。

等一下,可能有人会问:这个 τ 和 L' 是什么?其实和加密流程里的 S 盒变换及线性变换一模一样,只是加密时用的 L 变换是循环左移 2、10、18 位,而密钥扩展里的 L' 是左移 13、23 位。这个细节很重要——同样一个 S 盒,同样的 τ,但线性变换的移位参数不同。

我曾经踩过的坑:第一次实现 SM4 时,我偷懒把加密用的 L 变换直接复制到密钥扩展里,结果加解密始终对不上。查了两天 bug,最后发现 L' 的移位量差一点。记住:加密用 L,密钥扩展用 L',别混。

3.3 密钥扩展与加密/解密的关系

密钥扩展产生 32 个轮密钥后,加密时按顺序使用 rk[0]..rk[31],解密时反过来用 rk[31]..rk[0]。这其实是 SM4 结构对称性的体现——加解密可以共用同一个轮函数,只是轮密钥的顺序相反。这也是为什么很多人喜欢在实现里只做一个密钥扩展,然后加密和解密分别取正序和逆序的 rk 数组。

我用表格帮大家整理一下轮密钥的用法:

模式 轮密钥顺序 备注
加密rk[0], rk[1], ..., rk[31]正序
解密rk[31], rk[30], ..., rk[0]反序

你想想看,如果密钥扩展算错了,加密和解密的结果都会乱套。所以我建议在写完密钥扩展函数后,先用标准测试向量跑一遍,确认每个轮密钥和官方值一致,再去做加解密。

3.4 密钥扩展的 C 语言实现(核心片段)

下面是一个简单可运行的实现。我习惯把 S 盒、FK、CK 都定义成静态数组,避免函数调用开销:

// 假设 SBOX 已经定义好(256 字节)  
// FK 和 CK 数组略(标准值)
void sm4_key_expand(uint32_t mk[4], uint32_t rk[32]) {
    uint32_t k[36];  // 需要 36 个字,K0..K35
    int i;

    // 初始化:与 FK 异或
    for (i = 0; i < 4; i++) {
        k[i] = mk[i] ^ fk[i];
    }

    // 32 轮迭代
    for (i = 0; i < 32; i++) {
        uint32_t x = k[i+1] ^ k[i+2] ^ k[i+3] ^ ck[i];
        // τ 变换:4 个 S 盒
        uint32_t t = (SBOX[(x >> 24) & 0xFF] << 24) |
                     (SBOX[(x >> 16) & 0xFF] << 16) |
                     (SBOX[(x >>  8) & 0xFF] <<  8) |
                     (SBOX[ x        & 0xFF]);
        // L' 变换:左移 13、23 位异或
        uint32_t l = t ^ ROTL(t, 13) ^ ROTL(t, 23);
        // 计算新密钥
        k[i+4] = k[i] ^ l;
        // 输出轮密钥
        rk[i] = k[i+4];
    }
}

这里用到的 ROTL 宏是循环左移。注意看循环:k 数组大小是 36,因为我们最多用到 k[35](i 最大 31 时,计算 k[35])。

性能小窍门:如果平台支持 32 位算术,S 盒替换用查表法最直接。我在某国产芯片上做过优化,把 S 盒和移位合并成几条指令,但可读性变差。对云环境来说,直接用数组查表就可以了,代码可维护性更重要。

3.5 密钥扩展的整体流程

我把上面的所有步骤画成一张流程图,方便大家理解整个链条:

SM4 密钥扩展流程(32 轮迭代) 128 位主密钥 MK 拆分为 K0-K3,异或 FK0-FK3 32 轮迭代(i = 0..31) X = K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i] τ(X) → S盒 → L'(左移13,23) K[i+4] = K[i] ^ L' rk[i] = K[i+4](i=0..31)

3.6 避坑指南与个人经验

讲了这么多理论,最后分享几个实战中容易踩的坑:

  • K 数组大小至少 36:很多人只开了 32 个元素的数组,结果迭代到 i=28 时访问越界。我当时用一个小测试用例(i 循环到 32 检查边界)才发现的。
  • L' 不是 L:前面强调过,加密是左移 2、10、18,密钥扩展是左移 13、23。写代码时最好分别定义两个宏,或者用条件编译区分。
  • CK 数组别算错顺序:有些实现喜欢在循环内实时算 CK,但我建议查表。算错一个 CK 会导致从该轮开始全部密钥错位,而且很难肉眼查出来。
  • 大小端问题:SM4 标准用的是大端序。如果平台是小端(x86、ARM 基本都是),输入 MK 时要自己处理字节序转换。我曾经因为忽视了这一点,在 PC 上测出密钥扩展结果跟标准差一位,后来统一用 htonl / ntohl 包装一下解决。
重要提醒:永远不要在生产环境直接复制粘贴网上的密钥扩展代码。至少用 SM4 标准里的测试向量(比如 MK=0x0123456789abcdeffedcba9876543210,对应 rk[0] 是多少)做一次全验证。这一步能省掉你后面调 bug 的无数时间。

好了,关于密钥扩展就聊到这儿。总结一句话:密钥扩展的本质是用一个 128 位的“种子”经过 32 轮 S 盒和线性变换,生成 32 个彼此独立、不可预测的轮密钥。理解了这个,后面看加密和解密的主循环就轻松多了。