3. SM4 密钥扩展算法:轮密钥生成过程与代码实现

密钥扩展得做好,否则整个加密就是纸糊的。SM4 的轮密钥不像 AES 那样复杂,但细节也不少。我自己做商用密码改造时,就因为在密钥扩展里少加了一个异或顺序,导致加解密对不上,查了两天才发现。嗯,今天咱们就把这个环节彻底理清。

3.1 密钥扩展要解决什么问题

说白了,SM4 是个 32 轮迭代的 Feistel 网络。每轮需要一个 32 位的轮密钥。我们手上只有一把 128 位的主密钥。如何从这 128 位里,安全地生成 32 个不同的 32 位子密钥?这个过程就是密钥扩展。

你想想看,如果扩展算法太简单,比如直接截取,那轮密钥之间会有很强的线性关系,攻击者恢复出半个密钥就能推算出全部。SM4 的扩展算法用了非线性变换,保证了轮密钥的雪崩效果。我曾在一次合规审计中被问过:“你们的密钥扩展是否用到了 S 盒?” 答案必须是“用了”,否则不达标。

3.2 系统参数 FK 与固定参数 CK

SM4 的密钥扩展里有两组常数:FK(系统参数)和 CK(固定参数)。它们的作用是打散主密钥的规律性。

参数 值(十六进制) 用途
FK[0]A3B1BAC6与主密钥 K 异或后得到初始 K0~K3
FK[1]56AA3350
FK[2]677D9197
FK[3]B27022DC

CK 有 32 个值,每个 32 位。计算公式也挺简单:CK[i] = (t0, t1, t2, t3),其中 t = (4i, 4i+1, 4i+2, 4i+3) 当作字节,每个字节循环左移?不,实际上 CK 是通过特定线性变换生成的,这里不展开,你直接查表就行。我习惯在代码里把整个 CK 数组预计算好,避免运行时计算,性能更稳。

3.3 轮密钥生成步骤

整个流程分三步:初始化、迭代和输出。下面是我整理的步骤清单,你照着走肯定错不了。

  1. 初始化四个 32 位字:将 128 位主密钥 MK 拆成 4 个 32 位字 MK[0..3],然后与 FK 异或得到初始状态:
    K[i] = MK[i] ⊕ FK[i],i = 0..3
  2. 迭代计算 32 轮:对于 i = 0 到 31,
    K[i+4] = K[i] ⊕ T'(K[i+1] ⊕ K[i+2] ⊕ K[i+3] ⊕ CK[i])
    其中 T' 是合成变换。注意:这里的 T' 与加密轮函数中的 T 类似,但合成置换 L' 是线性变换,公式是 L'(x) = x ⊕ (x<<13) ⊕ (x<<23)。S 盒用的是同一个 S 盒。
  3. 输出轮密钥:每轮迭代得到 K[i+4] 之后,rk[i] = K[i+4]。一共 32 个 rk,对应 32 轮。
我的经验:注意 rk 的顺序。加密时从 rk[0] 到 rk[31] 依次用;解密时反过来,从 rk[31] 到 rk[0]。很多人以为密钥扩展也要反着算一遍,其实不用,解密只需要把 rk 数组逆序即可。我记得有一次接手一个项目,对方把扩展代码和解密代码混在一起,结果解密多算了一轮。后来我加了个注释:「rk[] 就是按加密顺序生成的,解密的轮密钥 = reverse(rk)」。

3.4 代码实现(C 语言风格)

下面是我在实际项目里用的关键代码段。每个函数只做一件事,调试时特别方便。

// S 盒表(256 个字节,这里只给示意)
static const uint8_t S[256] = { 0xD6, 0x90, ... };

// 线性变换 L'(用于密钥扩展)
uint32_t L_prime(uint32_t x) {
    return x ^ ROTL(x, 13) ^ ROTL(x, 23);
}

// T' 变换:S 盒 + L'
uint32_t T_prime(uint32_t z) {
    uint32_t t = (S[(z >> 24) & 0xFF] << 24) |
                 (S[(z >> 16) & 0xFF] << 16) |
                 (S[(z >> 8)  & 0xFF] << 8)  |
                 (S[ z        & 0xFF]);
    return L_prime(t);
}

// 密钥扩展主函数
void sm4_key_expansion(const uint8_t mk[16], uint32_t rk[32]) {
    uint32_t K[36];
    // 拆分成 4 个字,与 FK 异或
    for (int i = 0; i < 4; i++) {
        K[i] = GET_U32(mk, i*4) ^ FK[i];
    }
    // 迭代生成轮密钥
    for (int i = 0; i < 32; i++) {
        K[i+4] = K[i] ^ T_prime(K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i]);
        rk[i] = K[i+4];
    }
}
曾经踩过的坑:我曾经因为宏定义 ROTL 用了无符号整数移位,但在某些编译器上,移位 23 位时如果变量是 int 类型,可能造成算术移位(符号扩展)。后来我强制把所有参与移位的变量都改为 uint32_t,问题解决。还有,S 盒表一定要按字节序对齐,否则从存储器读到寄存器时会出意想不到的字节交换。

3.5 整体流程可视化

下图把密钥扩展的整个过程画出来了。你一眼就能看到输入、常数、迭代和输出之间的关系。

SM4 密钥扩展流程 主密钥 MK[0..3] 系统参数 FK[0..3] ⊕ K[0] ~ K[3] 对于 i = 0 .. 31 迭代 K[i+4] = K[i] ⊕ T'(K[i+1]⊕K[i+2]⊕K[i+3]⊕CK[i]) rk[i] = K[i+4] 固定参数 CK[0..31] 轮密钥 rk[0..31] T' 变换:使用 S 盒非线性置换 + 线性变换 L'(L'= x⊕(x<<13)⊕(x<<23))

3.6 快速验证与常见错误

你写完扩展代码后,用一组标准测试向量验一下。我习惯把下面的测试数据直接拷进单元测试里:

主密钥 第一个轮密钥 rk[0] 最后一个轮密钥 rk[31]
01 23 45 67 89 AB CD EF FE DC BA 98 76 54 32 10 F1 2B 7B D1 0F C2 9B 3B

我曾经遇到的坑:第一次写时,我把 L' 和加密轮函数里的 L 用混了。加密轮函数里 L(x) = x ⊕ (x<<2) ⊕ (x<<10) ⊕ (x<<18) ⊕ (x<<24),而密钥扩展中 L' 只有三个异或项。如果你复制了加密的 L 到扩展里,轮密钥就完全错了,加解密结果也会全乱。我当时调试了整整一个下午才发现。

另外还要注意 CK 数组的取值顺序。虽然大部分实现都直接查表,但有些给出了生成公式。我记得有一回对照国标,发现自己的 CK 和标准差了一位,原因是循环变量从 0 开始还是从 1 开始的混淆。你最好在代码开头加个断言,把已知的第一个 CK 硬编码对比一下,比如 assert(CK[0] == 0x00070E15)。

3.7 小结

SM4 的密钥扩展其实就三要素:FK 去相关性、CK 加非线性、T' 做扩散。把这三点抓住,代码就不会跑偏。我个人的习惯是,把扩展结果打印成 32 个 32 位 hex 值,然后用国标附录 B 的测试向量逐一比对。安全合规审查时,这一条是必查项。