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 位
00x00070E1500010101
10x001C232A00101010
20x0031383F00111111
30x00464D5401010100
40x005B626901101001
50x0070777E01111110
60x00858C9310010011
70x009AA1A810101000

看懂了吗?其实 CK[i] 的每个字节都在线性增长。它是按 (0x00070E15 + i * 0x001C232A) 这个等差数列算出来的。你想想看,这样设计的好处是什么?

  • 确定性:所有 SM4 实现算出的轮密钥完全一致。
  • 无碰撞:32 轮参数互不相同,确保每轮都“和而不同”。
  • 实现简单:可以预计算,也可以动态生成,资源开销极低。

3.4 完整的密钥扩展流程

把上面这些组件串起来,完整的密钥扩展流程就是这样的:

  1. 输入:128 比特主密钥 MK
  2. 初始化:K = MK ⊕ FK
  3. 循环 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]
  4. 输出:32 个 32 比特的轮密钥 (rk₀, rk₁, ..., rk₃₁)

下面这张 SVG 图能帮你直观理解整个流程:

SM4 密钥扩展流程图 128-bit 主密钥 MK K = MK ⊕ FK 32 轮迭代 (i=0 to 31) A = K₁⊕K₂⊕K₃⊕CK[i] τ(A) = S-box(A) L' 变换 rk[i] = K₀ ⊕ L'(τ(A)) 更新 K₀~K₃ → K₁~K₄ i++ 继续下一轮 输出 32 个轮密钥 rk[0..31]

这张图其实就说明了两件事:一是密钥扩展的输入输出关系,二是 32 轮迭代的内部结构。你如果看懂了这张图,整个密钥扩展就不再神秘了。

3.5 实践中的几个注意点

在实际编码时,有几个细节我建议你留意一下:

  • 预计算 vs 动态计算:如果你的应用场景是单次短连接,动态计算 32 轮密钥完全 OK,因为 SM4 的密钥扩展很快。但如果你要做并发压测,每秒可能要处理上千个连接,那就建议提前算好所有轮密钥,直接存成数组。
  • 寄存器重用:上面的公式里 K₀~K₃ 其实是 4 个 32 比特寄存器。每轮计算后只需要更新一个寄存器(K_{i+4}),其他 3 个往后挪。我见过有些实现硬开了 36 个变量的数组,完全没必要。
  • 字节序问题:SM4 是大端序。如果你在 x86 小端机器上实现,要小心字节序转换。我曾经因为少做了一次字节序调整,导致轮密钥和标准测试向量对不上,排查了半小时才发现问题。

警告:密钥扩展和加解密共用同一个 S 盒,这是标准规定的。但有些开发者为了优化,在密钥扩展里用了查表法,在加解密里用了门电路实现,结果两边的 S 盒输出对不上。记住,S 盒必须唯一且严格按标准实现。

对了,还有一个性能相关的点。你做并发数性能审计时,密钥扩展的时间占比其实很小,通常不到整个加密流程的 5%。所以如果要优化并发性能,重点应该放在加解密轮函数上,而不是密钥扩展。嗯,这个我们在后面章节再细聊。

总结一下这一节的核心:

  1. 密钥扩展用了 FK、CK 两类固定常量,确保所有实现一致性。
  2. 32 轮迭代结构相同,每轮生成 1 个 32 比特轮密钥。
  3. 非线性变换 τ 加线性变换 L' 是核心运算,L' 比加密时的 L 简单。
  4. 实现时注意预计算策略、寄存器复用和字节序,这些是常见坑点。

至此,SM4 的密钥扩展算法就说完了。下个知识点,咱们聊聊分组模式对并发性能的影响。