3. SM4密钥扩展算法:32轮轮密钥生成机制
密钥扩展,说白了就是怎么从一把主钥匙变出32把不同的子钥匙。SM4的轮密钥生成机制,我当年第一次看的时候觉得挺绕,后来在项目中手撸了一遍代码,才真正搞明白。今天咱们就把这事彻底说透。
3.1 密钥扩展的数学原理
SM4的主密钥是128位,也就是16个字节。我们需要从中生成32个32位的轮密钥,每轮加密用一把。你想想看,128位变出32×32=1024位,这中间肯定有讲究。
核心思路是这样的:
- 第一步:把128位主密钥分成4个32位的字,记为MK₀、MK₁、MK₂、MK₃
- 第二步:用固定常数FK₀~FK₃做一次异或,得到初始状态(K₀, K₁, K₂, K₃)
- 第三步:迭代32轮,每轮生成一个轮密钥rkᵢ
固定常数FK的值是固定的,我直接列出来:
| 参数 | 值(十六进制) |
|---|---|
| FK₀ | A3B1BAC6 |
| FK₁ | 56AA3350 |
| FK₂ | 677D9197 |
| FK₃ | B27022DC |
初始化的公式很简单:
K₀ = MK₀ ⊕ FK₀
K₁ = MK₁ ⊕ FK₁
K₂ = MK₂ ⊕ FK₂
K₃ = MK₃ ⊕ FK₃
然后从i=0到31,每轮这样算:
rkᵢ = Kᵢ₊₄ = Kᵢ ⊕ T'(Kᵢ₊₁ ⊕ Kᵢ₊₂ ⊕ Kᵢ₊₃ ⊕ CKᵢ)
这里T'是密钥扩展专用的非线性变换,和加密轮函数里的T有点像,但少了一步循环左移。CKᵢ是每轮固定的轮常数,一共32个。
关键区别:加密轮函数里的T变换包含"非线性τ + 循环左移24位",而密钥扩展的T'变换只有"非线性τ + 循环左移13位"。这个13位是我当年踩坑的地方,一开始写成了24位,结果加解密死活对不上。
3.2 32轮轮密钥生成流程
整个流程其实很规整,我画了张图帮你理清脉络:
嗯,这里要注意:CKᵢ的生成也是有规律的。每个CKᵢ是一个32位的字,由4个字节组成,每个字节的计算公式是:
ckᵢⱼ = (4i + j) × 7 (mod 256)
其中i是轮数(0~31),j是字节位置(0~3)。这个设计保证了每轮的常数都不一样,而且分布均匀。
我的经验:在实际项目中,CKᵢ可以预计算好存成常量数组,没必要每次运行都算一遍。32个值而已,占不了多少空间,但能省掉不少CPU周期。
3.3 密钥扩展代码实现
光讲理论不过瘾,咱们直接上代码。这是我个人习惯的写法,清晰且高效:
#include <stdint.h>
// SM4 S盒(和加密用的S盒完全一样)
static const uint8_t SBOX[256] = {
0xD6,0x90,0xE9,0xFE,0xCC,0xE1,0x3D,0xB7,0x16,0xB6,0x14,0xC2,0x28,0xFB,0x2C,0x05,
0x2B,0x67,0x9A,0x76,0x2A,0xBE,0x04,0xC3,0xAA,0x44,0x13,0x26,0x49,0x86,0x06,0x99,
0x9C,0x42,0x50,0xF4,0x91,0xEF,0x98,0x7A,0x33,0x54,0x0B,0x43,0xED,0xCF,0xAC,0x62,
0xE4,0xB3,0x1C,0xA9,0xC9,0x08,0xE8,0x95,0x80,0xDF,0x94,0xFA,0x75,0x8F,0x3F,0xA6,
0x47,0x07,0xA7,0xFC,0xF3,0x73,0x17,0xBA,0x83,0x59,0x3C,0x19,0xE6,0x85,0x4F,0xA8,
0x68,0x6B,0x81,0xB2,0x71,0x64,0xDA,0x8B,0xF8,0xEB,0x0F,0x4B,0x70,0x56,0x9D,0x35,
0x1E,0x24,0x0E,0x5E,0x63,0x58,0xD1,0xA2,0x25,0x22,0x7C,0x3B,0x01,0x21,0x78,0x87,
0xD4,0x00,0x46,0x57,0x9F,0xD3,0x27,0x52,0x4C,0x36,0x02,0xE7,0xA0,0xC4,0xC8,0x9E,
0xEA,0xBF,0x8A,0xD2,0x40,0xC7,0x38,0xB5,0xA3,0xF7,0xF2,0xCE,0xF9,0x61,0x15,0xA1,
0xE0,0xAE,0x5D,0xA4,0x9B,0x34,0x1A,0x55,0xAD,0x93,0x32,0x30,0xF5,0x8C,0xB1,0xE3,
0x1D,0xF6,0xE2,0x2E,0x82,0x66,0xCA,0x60,0xC0,0x29,0x23,0xAB,0x0D,0x53,0x4E,0x6F,
0xD5,0xDB,0x37,0x45,0xDE,0xFD,0x8E,0x2F,0x03,0xFF,0x6A,0x72,0x6D,0x6C,0x5B,0x51,
0x8D,0x1B,0xAF,0x92,0xBB,0xDD,0xBC,0x7F,0x11,0xD9,0x5C,0x41,0x1F,0x10,0x5A,0xD8,
0x0A,0xC1,0x31,0x88,0xA5,0xCD,0x7B,0xBD,0x2D,0x74,0xD0,0x12,0xB8,0xE5,0xB4,0xB0,
0x89,0x69,0x97,0x4A,0x0C,0x96,0x77,0x7E,0x65,0xB9,0xF1,0x09,0xC5,0x6E,0xC6,0x84,
0x18,0xF0,0x7D,0xEC,0x3A,0xDC,0x4D,0x20,0x79,0xEE,0x5F,0x3E,0xD7,0xCB,0x39,0x48
};
// 固定常数FK
static const uint32_t FK[4] = {
0xA3B1BAC6, 0x56AA3350, 0x677D9197, 0xB27022DC
};
// 轮常数CK(预计算)
static uint32_t CK[32];
// 初始化CK表
void init_ck(void) {
for (int i = 0; i < 32; i++) {
uint32_t val = 0;
for (int j = 0; j < 4; j++) {
val = (val << 8) | ((4 * i + j) * 7 % 256);
}
CK[i] = val;
}
}
// 32位循环左移
#define ROTL(x, n) (((x) << (n)) | ((x) >> (32 - (n))))
// 密钥扩展T'变换:τ + 循环左移13位
static uint32_t T_prime(uint32_t x) {
uint8_t *b = (uint8_t *)&x;
uint32_t res = 0;
res |= (uint32_t)SBOX[b[0]] << 24;
res |= (uint32_t)SBOX[b[1]] << 16;
res |= (uint32_t)SBOX[b[2]] << 8;
res |= (uint32_t)SBOX[b[3]];
return ROTL(res, 13);
}
// SM4密钥扩展主函数
void sm4_key_expand(const uint8_t *mk, uint32_t rk[32]) {
uint32_t K[36];
// 初始化K₀~K₃
for (int i = 0; i < 4; i++) {
K[i] = ((uint32_t)mk[4*i] << 24) |
((uint32_t)mk[4*i+1] << 16) |
((uint32_t)mk[4*i+2] << 8) |
(uint32_t)mk[4*i+3];
K[i] ^= FK[i];
}
// 生成32轮轮密钥
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];
}
}
我曾经踩过的坑:
- 字节序问题:SM4标准是大端序,如果你的平台是小端序(x86就是),从字节数组拼uint32_t时一定要用左移,不能直接memcpy。我当年图省事用了memcpy,结果调试了一整天。
- S盒复用:密钥扩展和加密用的是同一个S盒,别自己另搞一套。标准里写得很清楚,但有人会想当然地改。
- 循环左移位数:T'是13位,不是24位。这个错了,加解密一定对不上。
3.4 轮密钥的使用顺序
生成好的32个轮密钥,在加密和解密时使用顺序是相反的:
| 模式 | 第1轮 | 第2轮 | ... | 第32轮 |
|---|---|---|---|---|
| 加密 | rk₀ | rk₁ | ... | rk₃₁ |
| 解密 | rk₃₁ | rk₃₀ | ... | rk₀ |
这个设计很巧妙——解密时只需要把轮密钥数组倒过来用就行,不需要重新算一遍。我在项目中就是这么干的,省时省力。
性能优化建议:如果你的系统内存够用,可以把加密和解密的轮密钥数组都预先生成好。加密用rk_enc[32],解密用rk_dec[32](就是rk_enc的逆序)。这样在切换加解密时零延迟。
好了,SM4密钥扩展的核心内容就这些。说白了就是:4个初始值 + 32轮迭代 + S盒非线性变换 + 循环左移13位。代码量不大,但细节不少。你写的时候多注意字节序和左移位数的细节,基本不会出问题。