3. SM4 密钥扩展算法:密钥调度与轮密钥生成
讲完了加解密流程,咱们得聊聊密钥扩展了。说白了,SM4 用的是一个 128 比特的主密钥,但加密过程需要 32 个轮密钥。这 32 个轮密钥怎么来的?就是靠密钥扩展算法算出来的。
我个人习惯把密钥扩展看作一个“种子发芽”的过程。你给它一个 128 比特的种子(主密钥),它就能生长出 32 片叶子(轮密钥)。这片叶子怎么长,长成什么样,就是本章要说的核心。
3.1 密钥调度:从主密钥到中间密钥
先看密钥调度的第一步。SM4 的主密钥 MK 是 4 个 32 比特的字:(MK₀, MK₁, MK₂, MK₃)。我们先用一个固定的系统参数 FK 和它做异或,得到中间变量 K。
// 系统参数 FK,这是国标里写死的
FK₀ = 0xA3B1BAC6
FK₁ = 0x56AA3350
FK₂ = 0x677D9197
FK₃ = 0xB27022DC
// 初始密钥调度
(K₀, K₁, K₂, K₃) = (MK₀ ⊕ FK₀, MK₁ ⊕ FK₁, MK₂ ⊕ FK₂, MK₃ ⊕ FK₃)
这里有个容易忽略的点:FK 是 16 进制常量,不是随便取的。我当初看标准时还专门查了它的来源,其实是基于某些数学常数生成的。你直接当魔数记住就行,不用纠结为什么是这几个数。
3.2 轮密钥生成:32 轮的迭代运算
得到初始 K 后,就要开始迭代生成 32 个轮密钥了。每一轮的计算公式很简单:
// rk[i] 是第 i 轮的轮密钥
// CK[i] 是第 i 轮的固定参数
// T' 是一个合成置换(非线性变换 τ + 线性变换 L')
for i in range(32):
rk[i] = K_{i+4} = K_i ⊕ T'(K_{i+1} ⊕ K_{i+2} ⊕ K_{i+3} ⊕ CK[i])
嗯,这里要注意,T' 变换和加密轮函数里的 T 变换不一样。加密时用的是 L,密钥扩展时用的是 L'。L' 只是一个简单的 32 比特循环左移:
// L'(B) = B ⊕ (B <<< 13) ⊕ (B <<< 23)
// 这比加密里的 L 变换(左移 2、10、18、24 位)简单多了
为什么这样做?我个人的理解是:密钥扩展需要更高的计算效率,同时保证足够的扩散性。L' 用 13 和 23 这两个移位量,刚好能覆盖整个 32 比特空间,性价比很高。
避坑指南:我曾经在实现时把 T' 和 T 搞混了,结果加密出来的密文自己都解不回去。排查了一下午才发现是 L 和 L' 用反了。记住:加密用 L,密钥扩展用 L'。
3.3 CK 参数:32 轮固定的“盐”
每轮还有一个 CK 参数。CK 怎么来的?也是国标里写死的常量。它本质上是一个 32 比特的计数器,每一轮都不一样。我列一下前几轮的值,你感受一下规律:
| 轮次 i | CK[i] (十六进制) | 二进制低位 8 位 |
|---|---|---|
| 0 | 0x00070E15 | 00010101 |
| 1 | 0x001C232A | 00101010 |
| 2 | 0x0031383F | 00111111 |
| 3 | 0x00464D54 | 01010100 |
| 4 | 0x005B6269 | 01101001 |
| 5 | 0x0070777E | 01111110 |
| 6 | 0x00858C93 | 10010011 |
| 7 | 0x009AA1A8 | 10101000 |
看懂了吗?其实 CK[i] 的每个字节都在线性增长。它是按 (0x00070E15 + i * 0x001C232A) 这个等差数列算出来的。你想想看,这样设计的好处是什么?
- 确定性:所有 SM4 实现算出的轮密钥完全一致。
- 无碰撞:32 轮参数互不相同,确保每轮都“和而不同”。
- 实现简单:可以预计算,也可以动态生成,资源开销极低。
3.4 完整的密钥扩展流程
把上面这些组件串起来,完整的密钥扩展流程就是这样的:
- 输入:128 比特主密钥 MK
- 初始化:K = MK ⊕ FK
- 循环 32 轮:
- 计算 A = K_{i+1} ⊕ K_{i+2} ⊕ K_{i+3} ⊕ CK[i]
- 对 A 做非线性变换 τ(4 个 S 盒并行查表)
- 对 τ(A) 做线性变换 L'(循环左移 13、23 位再异或)
- rk[i] = K_i ⊕ L'(τ(A))
- 更新 K_{i+4} = rk[i]
- 输出:32 个 32 比特的轮密钥 (rk₀, rk₁, ..., rk₃₁)
下面这张 SVG 图能帮你直观理解整个流程:
这张图其实就说明了两件事:一是密钥扩展的输入输出关系,二是 32 轮迭代的内部结构。你如果看懂了这张图,整个密钥扩展就不再神秘了。
3.5 实践中的几个注意点
在实际编码时,有几个细节我建议你留意一下:
- 预计算 vs 动态计算:如果你的应用场景是单次短连接,动态计算 32 轮密钥完全 OK,因为 SM4 的密钥扩展很快。但如果你要做并发压测,每秒可能要处理上千个连接,那就建议提前算好所有轮密钥,直接存成数组。
- 寄存器重用:上面的公式里 K₀~K₃ 其实是 4 个 32 比特寄存器。每轮计算后只需要更新一个寄存器(K_{i+4}),其他 3 个往后挪。我见过有些实现硬开了 36 个变量的数组,完全没必要。
- 字节序问题:SM4 是大端序。如果你在 x86 小端机器上实现,要小心字节序转换。我曾经因为少做了一次字节序调整,导致轮密钥和标准测试向量对不上,排查了半小时才发现问题。
警告:密钥扩展和加解密共用同一个 S 盒,这是标准规定的。但有些开发者为了优化,在密钥扩展里用了查表法,在加解密里用了门电路实现,结果两边的 S 盒输出对不上。记住,S 盒必须唯一且严格按标准实现。
对了,还有一个性能相关的点。你做并发数性能审计时,密钥扩展的时间占比其实很小,通常不到整个加密流程的 5%。所以如果要优化并发性能,重点应该放在加解密轮函数上,而不是密钥扩展。嗯,这个我们在后面章节再细聊。
总结一下这一节的核心:
- 密钥扩展用了 FK、CK 两类固定常量,确保所有实现一致性。
- 32 轮迭代结构相同,每轮生成 1 个 32 比特轮密钥。
- 非线性变换 τ 加线性变换 L' 是核心运算,L' 比加密时的 L 简单。
- 实现时注意预计算策略、寄存器复用和字节序,这些是常见坑点。
至此,SM4 的密钥扩展算法就说完了。下个知识点,咱们聊聊分组模式对并发性能的影响。