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 轮密钥生成步骤
整个流程分三步:初始化、迭代和输出。下面是我整理的步骤清单,你照着走肯定错不了。
- 初始化四个 32 位字:将 128 位主密钥 MK 拆成 4 个 32 位字
MK[0..3],然后与 FK 异或得到初始状态:
K[i] = MK[i] ⊕ FK[i],i = 0..3 - 迭代计算 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 盒。 - 输出轮密钥:每轮迭代得到
K[i+4]之后,rk[i] = K[i+4]。一共 32 个 rk,对应 32 轮。
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];
}
}
uint32_t,问题解决。还有,S 盒表一定要按字节序对齐,否则从存储器读到寄存器时会出意想不到的字节交换。
3.5 整体流程可视化
下图把密钥扩展的整个过程画出来了。你一眼就能看到输入、常数、迭代和输出之间的关系。
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 的测试向量逐一比对。安全合规审查时,这一条是必查项。