4. SM4 密钥扩展算法:密钥调度与轮密钥生成

SM4 的密钥扩展,说白了就是把你的 128 位初始密钥,转换成 32 个 32 位的轮密钥。这 32 个轮密钥,每一轮加解密都会用到。我自己刚开始接触 SM4 时,总觉得密钥扩展就是简单复制几轮,结果写出来直接跑飞。嗯,这里面的细节,咱们还是得仔细梳理一遍。

4.1 密钥调度:从 128 位到 32 个 32 位字的拆分

你先拿到的是一把 128 位的原始密钥,记为 MK = (MK₀, MK₁, MK₂, MK₃),每个 MK 是 32 位。SM4 在正式进入 32 轮迭代之前,会先把这些 32 位字做一些“预处理”操作。我见过有人直接把 MK₀~MK₃ 扔进迭代,结果生成的轮密钥完全不对。注意,这里有一个固定异或参数——系统参数 FK。

FK 有 4 个常数,分别是:

FK 值(十六进制)
FK₀ = A3B1BAC6
FK₁ = 56AA3350
FK₂ = 677D9197
FK₃ = B27022DC

讲一下预处理公式:

(K₀, K₁, K₂, K₃) = (MK₀ ⊕ FK₀, MK₁ ⊕ FK₁, MK₂ ⊕ FK₂, MK₃ ⊕ FK₃)

这个操作相当于把 MK 和 FK 做了一次“白化”。我曾经在某个国产加密卡项目中,因为没注意 FK 的字节序(大小端问题),导致加解密结果跟标准测试向量对不上,排查了整整一下午。所以建议你在实现时,先把 FK 和 MK 按字节序统一,再做异或。

4.2 轮密钥生成:32 轮迭代的核心逻辑

得到 K₀~K₃ 后,接下来就要跑 32 轮,生成 32 个轮密钥 rk₀ ~ rk₃₁。每一轮的公式如下:

rk_i = K_{i+4} = K_i ⊕ T'(K_{i+1} ⊕ K_{i+2} ⊕ K_{i+3} ⊕ CK_i)

其中,T'() 是一个“非线性变换 + 线性变换”的复合函数,跟加解密里的 T 变换类似,但线性部分做了一个简单的循环左移 13 位(不是加解密中的 2、10、18 位)。你想想看,SM4 的设计者为什么要这么改?其实是为了让轮密钥的扩散性更好,避免密钥泄露后反推初始密钥。

再说说 CK_i。它是一个 32 位的固定参数,每一轮不同。CK 的生成规则也很机械:

  • 将 4 个字节分别记为 ck_i,j,其中 j = 0,1,2,3
  • ck_i,j = (4×i + j) × 7 mod 256

说白了,就是从 0 开始,每次加 7,取模 256,按顺序填满 4 个字节,凑成 32 位。我个人习惯在代码中预计算一个 32 个元素的 ck_data 数组,运行时直接查表,速度更快。

4.3 核心变换 T' 拆解

T' 先做非线性变换(S 盒替换),再做线性变换。SM4 的 S 盒跟 AES 不一样,是一个 8×8 的置换表。我手头常用的 Python 实现会直接硬编码 S 盒为一个列表。

线性变换部分:

L'(B) = B ⊕ (B <<< 13) ⊕ (B <<< 23)

注意这里的循环左移位数。加解密里的 L 是 (B ⊕ (B<<<2) ⊕ (B<<<10) ⊕ (B<<<18) ⊕ (B<<<24)),密钥扩展的 L' 要简单一些。为什么?因为轮密钥不需要像数据那样强的扩散,省一点开销。

4.4 完整流程一览

步骤操作说明
1拆分 MK 为 4 个 32 位字MK₀, MK₁, MK₂, MK₃
2与 FK 异或K₀~K₃ = MK ⊕ FK
3对 i = 0..31 迭代计算 rk_i = K_{i+4}
3.1合并输入X = K_{i+1} ⊕ K_{i+2} ⊕ K_{i+3} ⊕ CK_i
3.2非线性变换Y = S盒替换(X 的每个字节)
3.3线性变换 L'Z = Y ⊕ (Y<<<13) ⊕ (Y<<<23)
3.4生成新 KK_{i+4} = K_i ⊕ Z
避坑指南:我曾经在迭代顺序上犯过错误——把 rk_i 记成了 K_i ⊕ ... 但实际应该是 K_{i+4} = K_i ⊕ ...。别搞反。

4.5 Python 代码示例(核心片段)

def sm4_key_expand(mk):
    # FK 常量
    FK = (0xA3B1BAC6, 0x56AA3350, 0x677D9197, 0xB27022DC)
    # CK 预计算
    CK = []
    for i in range(32):
        ck = 0
        for j in range(4):
            ck = (ck << 8) | ((4*i + j) * 7 % 256)
        CK.append(ck)

    K = [0] * 36
    for i in range(4):
        K[i] = mk[i] ^ FK[i]

    rk = [0] * 32
    for i in range(32):
        x = K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i]
        # 非线性变换:S盒替换
        a = sm4_s_box(x)
        # 线性变换 L'
        a = a ^ rotate_left(a, 13) ^ rotate_left(a, 23)
        K[i+4] = K[i] ^ a
        rk[i] = K[i+4]

    return rk

这个函数里 sm4_s_box 是查表替换,rotate_left 是循环左移。我通常会把这些辅助函数单独封装,方便测试和复用。

4.6 一张图看懂密钥扩展

下面我把整个流程画成了结构图。节点和箭头清晰展示了 MK 如何一步步变成 32 个轮密钥。

SM4 密钥扩展流程 128 位初始密钥 MK 拆分 4 个 32 位字 MK ⊕ FK 得到 K₀~K₃ 对 i = 0..31 迭代计算 X = K_{i+1}⊕K_{i+2}⊕K_{i+3}⊕CK_i Y = S盒替换 → L' 线性变换 K_{i+4} = K_i ⊕ Y 轮密钥 rk_i i++ 关键说明 • CK_i 是固定参数,每个字节 = (4×i + j) × 7 mod 256 • T' 变换中线性部分 L' 仅左移 13 和 23 位,区别于加解密中的多移位组合 • 最终输出 32 个 32 位轮密钥,供加解密每一轮使用

4.7 关于性能与实现的几点经验

讲个实际案例。我在一个嵌入式设备上移植 SM4,CPU 只有 72MHz,RAM 很紧张。当时我直接把 CK 在运行时计算,结果每次启动都要算一遍,慢得不能忍。后来改成预计算静态数组,一劳永逸。你如果做嵌入式开发,我建议把 FK、CK 和 S 盒都写在只读数据段里,省空间又提速。

另外,注意字节序。SM4 标准文档里用的是大端序,但很多参考代码(尤其是 Python 实现的)习惯用小端模拟。我曾经因为这个问题跟同事吵了一架,最后发现是双方对 MK 的输入解析方式不同。解决办法:统一在算法入口处做一次字节序转换,内部全部按标准走。

⚠️ 小心越界:代码中的 K 数组长度要申请 36 个元素(0~35),因为迭代用到 K[4]~K[35]。只申请 32 个会越界访问,导致未定义行为。我见过有人因此而崩溃。

好,密钥扩展的逻辑就梳理到这儿。接下来你就能拿着这些轮密钥,去进行加解密运算了。记住,密钥扩展可以预计算,也可以在线实时算,看你的场景需求。我个人倾向预计算,一劳永逸。