3. SM4 密钥长度与密钥扩展算法归纳总结
说实话,我见过不少刚接触 SM4 的同学,上来就问“密钥是不是越长越好?”嗯,这个问题其实挺有意思。SM4 的密钥长度是固定的 128 位,不像 AES 还能选 192、256。但长度固定不代表扩展过程简单,里面有不少坑。今天我把这块彻底拆开给你讲明白。
3.1 密钥长度:128 位,不多不少
SM4 的密钥长度写在国标里:128 bits,也就是 16 字节。为什么是 128?
我在项目里用过 SM4,也对比过其他国密算法。128 位在安全性和性能之间做了个不错的平衡。你想想,现在芯片级实现 SM4,128 位密钥的硬件面积和功耗都是很成熟的。
关键点:SM4 不支持其他密钥长度。如果你看到有人传进来 192 位密钥,那肯定是错误的。我排查过几次这样的 case,都是上层把 ASCII 字符串直接当密钥用,没做哈希截断。
| 属性 | SM4 | AES-128 |
|---|---|---|
| 密钥长度 | 128 bits (固定) | 128 bits |
| 分组长度 | 128 bits | 128 bits |
| 轮数 | 32 轮 | 10 轮 |
| 密钥扩展方式 | 32 位字轮迭代 | 字扩展+轮常数 |
你看,密钥长度保持一致,但轮数多了不少。这跟扩展算法和 S 盒的设计有关。
3.2 密钥扩展算法:从 4 个字到 32 个字
SM4 的密钥扩展,说白了就是把初始的 128 位密钥变成 32 个轮密钥,每轮用 1 个 32 位字。这个过程的逻辑很强,但实现起来容易踩坑。
第一步:原始密钥与系统参数异或
初始密钥分成 4 个 32 位字:MK₀, MK₁, MK₂, MK₃。然后跟系统参数 FK 异或,得到中间变量 K₀~K₃:
K₀ = MK₀ ⊕ FK₀
K₁ = MK₁ ⊕ FK₁
K₂ = MK₂ ⊕ FK₂
K₃ = MK₃ ⊕ FK₃
这里的 FK 是固定的系统参数,国标里给了四个 32 位常量。我刚开始做的时候觉得这不就是个 XOR 吗,能有什么问题?后来发现有人在实现里把 FK 写反了顺序,导致整个轮密钥全错。
警告:FK₀ ~ FK₃ 的值必须严格按国标 BM/GM/T 0002-2012 附录。我曾经遇到一个第三方库,把所有 FK 写成了 0xFFFFFFFF,结果加解密错乱。查了两天才定位到。
第二步:32 轮迭代生成轮密钥
从 K₀~K₃ 开始,第 i 轮(i=0~31)生成轮密钥 rkᵢ:
rkᵢ = Kᵢ₊₄ = Kᵢ ⊕ T'(Kᵢ₊₁ ⊕ Kᵢ₊₂ ⊕ Kᵢ₊₃ ⊕ CKᵢ)
其中 T' 是合成置换,包含非线性变换和线性变换。非线性变换就是 4 个并行的 S 盒,每个 S 盒把 8 位输入映射成 8 位输出。线性变换是个 32 位循环左移再异或的操作:
L'(B) = B ⊕ (B <<< 13) ⊕ (B <<< 23)
这里的 CKᵢ 是轮常数,也是国标写好的 32 个固定值。我建议你直接查表,不用自己算,容易算错。
3.3 轮密钥生成全流程
为了方便你理解,我画了张流程图。记住,这个流程跟加密流程很像,但线性变换 L' 跟加密时的 L 不一样——加密用的是 L(B) = B ⊕ (B<<<2) ⊕ (B<<<10) ⊕ (B<<<18) ⊕ (B<<<24)。千万别搞混。
技巧:密钥扩展可以预先计算好并存起来,尤其是做 ECB/CTR 等非 CBC 模式时,每次加密只算一次扩展就够了。我在嵌入式项目里这样优化过,速度提升了 2 倍。
3.4 避坑指南:密钥扩展里常见的错误
这些年我 review 过不少 SM4 的实现,总结几个高频问题:
- FK 或 CK 用错:有人用加密轮数减了 1 对应的 CK,或者把 FK 和 CK 搞反。查表一定要逐字节对。
- S 盒查表实现未对齐:SM4 S 盒是 8→8 的映射,有些人在实现时把输入字节当成索引,但没做大小端转换。如果你的平台是小端,直接按数组下标查就行。
- 线性变换 L' 与 L 搞混:加密时用的是 L,密钥扩展用的是 L'。差之毫厘,谬以千里。我亲眼见过一个测试向量全是反例,最后发现是 L 用错了。
- 轮密钥生成顺序颠倒:加密时用 rk₀, rk₁, … rk₃₁;解密时用 rk₃₁, rk₃₀, … rk₀。不能用同一顺序。
曾经踩过的坑:有一次我在芯片里硬件实现密钥扩展,以为用加法器替代 XOR 会更高效,结果仿真全错。SM4 的 XOR 就是 XOR,不能偷懒换成加法,因为进位会破坏线性性质。查了两天波形才明白——嗯,从此老老实实用 XOR。
3.5 轮密钥的存储与实例
32 个轮密钥,每个 4 字节,一共 128 字节。常见的做法是开一个 uint32_t rk[32] 数组。如果你在 ARM Cortex-M 上做,可以放在 SRAM 里,或者在初始化时先扩展好。举例:
// SM4 密钥扩展示例(伪C代码)
void sm4_key_expand(uint8_t key[16], uint32_t rk[32]) {
uint32_t K[36];
// 1. 初始4个字
for (int i = 0; i < 4; i++) {
K[i] = (key[4*i] << 24) | (key[4*i+1] << 16) |
(key[4*i+2] << 8) | key[4*i+3];
K[i] ^= FK[i]; // 与系统参数异或
}
// 2. 32轮迭代
for (int i = 0; i < 32; i++) {
uint32_t B = K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i];
B = tau(B); // 非线性变换:4个S盒
B = l_prime(B); // 线性变换 L'
K[i+4] = K[i] ^ B;
rk[i] = K[i+4];
}
}
注意 tau 函数内部就是 4 个 S 盒查表,l_prime 就是循环移位。你细看这段代码,会发现它其实跟加密流程里的轮函数很像,只是少了那个常数。
3.6 密钥扩展的安全性考量
我一直强调:密钥扩展本身不是随机数生成器。它只是把原始密钥的信息均匀扩散到 32 个轮密钥中。所以你原始密钥的熵有多高,最终的安全性就有多高。不要用“1234567890abcdef”这种弱密钥,SM4 一样扛不住暴力猜解。
另外,密钥扩展算法是可逆的。如果你拿到了任何一个轮密钥,理论上可以逆向推出原始密钥(虽然计算量不小)。所以在实际系统中,轮密钥也要当成敏感数据保护——别直接暴露在 JTAG 调试接口上。
个人建议:做产品时,密钥扩展和加密函数尽量放在同一个安全的内存区域,完成后马上擦除临时缓冲区。我在 IoT 固件中见过有人把扩展后的 32 个轮密钥明文写进日志,那简直是给攻击者送弹药。
3.7 小结:记住这几个数字
| 要素 | 值 |
|---|---|
| 密钥长度 | 128 bit (16 byte) |
| 轮密钥总数 | 32 个(每个 32 bit) |
| 系统参数 FK | 4 个固定 32 位常量 |
| 轮常数 CK | 32 个固定 32 位常量 |
| S 盒 | 1 个 8×8 查表,重复 4 次 |
| 扩展中线性变换 L' | B ⊕ (B<<<13) ⊕ (B<<<23) |
好了,密钥扩展这块核心内容就到这。你如果能把上面这个流程手写一遍,基本就掌握了 SM4 密钥扩展的精髓。下回我们再聊加密轮函数,那又是另一层门道。