3. SM4分组密码算法:从结构到代码实现
SM4,这个算法我最早接触是在2012年做金融IC卡项目的时候。当时国密局刚发布标准,我们团队是第一批吃螃蟹的人。说实话,刚开始看规范文档,那叫一个头大。但真正上手实现过一遍之后,你会发现它的设计其实很精巧——比AES简单,但该有的安全强度一点不少。
这一章,咱们就把SM4的里里外外掰扯清楚。从算法结构到密钥扩展,从轮函数到四种工作模式,最后我会给出一个可以直接用的C语言实现。嗯,都是我在项目中踩过坑之后沉淀下来的东西。
3.1 SM4算法总体结构
SM4是一个分组密码算法,分组长度128位,密钥长度也是128位。它采用Feistel结构,共32轮迭代。你想想看,AES用的是SPN结构,SM4用的是Feistel——这两种结构各有千秋。Feistel的好处是加密和解密可以共用同一套逻辑,只是轮密钥的使用顺序相反。
我个人习惯把SM4的结构分成三层来看:
- 顶层:32轮Feistel迭代,每轮处理64位数据
- 中间层:轮函数F,包含合成置换T
- 底层:S盒、线性变换L、密钥扩展算法
下面这张图是我当年给团队培训时画的,现在看依然清晰:
加密过程可以概括为:先把128位明文分成4个32位字 (X0, X1, X2, X3),然后执行32轮迭代。每轮用轮函数F更新一个32位字。最后把 (X32, X33, X34, X35) 做反序变换,得到密文。
我的经验:解密时直接把轮密钥顺序反过来就行——rk[31]变成第一轮,rk[0]变成最后一轮。Feistel结构的好处就在这里,加密和解密的代码几乎一模一样。我曾经见过有人把加解密写成两套函数,其实完全没必要。
3.2 密钥扩展:从128位主密钥到32个轮密钥
密钥扩展,说白了就是把你的128位主密钥,变成32个32位的轮密钥。SM4的密钥扩展算法和加密轮函数很像,只是线性变换L换成了L'。
具体流程是这样的:
- 把128位主密钥MK分成4个32位字 (MK0, MK1, MK2, MK3)
- 用系统参数FK做异或初始化:(K0, K1, K2, K3) = (MK0⊕FK0, MK1⊕FK1, MK2⊕FK2, MK3⊕FK3)
- 对 i=0..31,计算:K[i+4] = K[i] ⊕ T'(K[i+1]⊕K[i+2]⊕K[i+3]⊕CK[i])
- 轮密钥 rk[i] = K[i+4]
这里有两个固定参数表:
| 参数 | 说明 | 值(十六进制) |
|---|---|---|
| FK0 | 系统参数1 | A3B1BAC6 |
| FK1 | 系统参数2 | 56AA3350 |
| FK2 | 系统参数3 | 677D9197 |
| FK3 | 系统参数4 | B27022DC |
| CK[0..31] | 32个固定密钥参数 | 由特定公式生成(见代码) |
注意:密钥扩展中的T'和加密轮函数中的T不一样。T'的线性变换L'是:B ⊕ (B<<<13) ⊕ (B<<<23),而加密用的L是:B ⊕ (B<<<2) ⊕ (B<<<10) ⊕ (B<<<18) ⊕ (B<<<24)。千万别搞混了,我曾经在代码审查时发现有人把这两个写反了,结果加解密对不上。
3.3 轮函数与合成置换T
轮函数F是SM4的核心。每轮输入4个32位字 (X[i], X[i+1], X[i+2], X[i+3]) 和一个轮密钥 rk[i],输出一个新的32位字:
F(X[i], X[i+1], X[i+2], X[i+3], rk[i]) = X[i] ⊕ T(X[i+1]⊕X[i+2]⊕X[i+3]⊕rk[i])
合成置换T由两部分组成:
- 非线性变换τ:把32位输入分成4个字节,每个字节过S盒。SM4的S盒是8×8的,共256个元素。这个S盒的设计有严格的密码学性质——差分均匀性、非线性度、代数次数都经过优化。
- 线性变换L:对τ的输出做循环移位和异或操作。公式是:
B ⊕ (B<<<2) ⊕ (B<<<10) ⊕ (B<<<18) ⊕ (B<<<24)
你想想看,为什么L要选这几个移位位数?其实是为了达到扩散效果——让一个比特的变化能快速影响到其他比特。32轮迭代下来,雪崩效应非常充分。
3.4 SM4的加密解密模式
SM4本身只定义了128位分组的加密变换。实际应用中,我们需要用工作模式来处理任意长度的数据。国密标准里主要定义了三种模式:ECB、CBC、CTR。我按自己的理解给你梳理一下:
| 模式 | 特点 | 适用场景 | 缺点 |
|---|---|---|---|
| ECB | 每个分组独立加密 | 密钥分发、短数据加密 | 相同明文得到相同密文,不安全 |
| CBC | 前一个密文参与当前分组加密 | 文件加密、通用数据保护 | 不能并行,有传播错误 |
| CTR | 用计数器生成密钥流,与明文异或 | 流式加密、高性能场景 | 计数器不能重复使用 |
ECB模式:最简单,但也是最容易出问题的。我记得有一次做安全审计,发现某个系统用ECB模式加密用户头像——头像轮廓都看得一清二楚。所以除非你确定数据是随机的(比如密钥本身),否则别用ECB。
CBC模式:需要初始化向量IV,IV必须随机且每次加密都不同。解密时IV不需要保密,但不能被篡改。我在项目中习惯把IV放在密文前面一起传输。
CTR模式:我最喜欢用的模式。它把SM4变成了流密码,可以并行计算,而且不需要填充。计数器通常用nonce+分组序号的方式构造。注意计数器绝对不能重复,否则异或操作会把密钥流暴露出来。
避坑指南:我曾经在一个物联网项目里用CTR模式,计数器从0开始累加。结果设备重启后计数器重置,用同样的密钥加密了同样的数据——密钥流重复了。从那以后,我养成了用设备ID+时间戳作为nonce的习惯。
3.5 SM4的代码实现
下面是我在实际项目中用过的C语言实现。为了清晰,我保留了核心逻辑,去掉了平台相关的优化。你可以直接拿去用:
#include <stdint.h>
#include <string.h>
/* 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
};
/* 循环左移 */
#define ROTL(x, n) (((x) << (n)) | ((x) >> (32 - (n))))
/* 非线性变换τ */
static uint32_t tau(uint32_t a) {
uint8_t b[4];
b[0] = SBOX[(a >> 24) & 0xFF];
b[1] = SBOX[(a >> 16) & 0xFF];
b[2] = SBOX[(a >> 8) & 0xFF];
b[3] = SBOX[a & 0xFF];
return ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
((uint32_t)b[2] << 8) | b[3];
}
/* 线性变换L(加密用) */
static uint32_t L(uint32_t b) {
return b ^ ROTL(b, 2) ^ ROTL(b, 10) ^ ROTL(b, 18) ^ ROTL(b, 24);
}
/* 线性变换L'(密钥扩展用) */
static uint32_t L_prime(uint32_t b) {
return b ^ ROTL(b, 13) ^ ROTL(b, 23);
}
/* 合成置换T */
static uint32_t T(uint32_t a) {
return L(tau(a));
}
/* 合成置换T' */
static uint32_t T_prime(uint32_t a) {
return L_prime(tau(a));
}
/* 系统参数FK */
static const uint32_t FK[4] = {
0xA3B1BAC6, 0x56AA3350, 0x677D9197, 0xB27022DC
};
/* 固定参数CK */
static uint32_t CK[32];
static int ck_initialized = 0;
static void init_ck(void) {
if (ck_initialized) return;
for (int i = 0; i < 32; i++) {
uint32_t val = 0;
for (int j = 0; j < 4; j++) {
int t = (4 * i + j) * 7;
val = (val << 8) | ((t % 256) & 0xFF);
}
CK[i] = val;
}
ck_initialized = 1;
}
/* 密钥扩展 */
void sm4_key_expand(const uint8_t *key, uint32_t rk[32]) {
uint32_t K[36];
init_ck();
/* 初始化K0..K3 */
for (int i = 0; i < 4; i++) {
K[i] = ((uint32_t)key[4*i] << 24) | ((uint32_t)key[4*i+1] << 16) |
((uint32_t)key[4*i+2] << 8) | key[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];
}
}
/* 加密一个分组 */
void sm4_encrypt_block(const uint32_t rk[32], const uint8_t *in, uint8_t *out) {
uint32_t X[36];
/* 加载明文 */
for (int i = 0; i < 4; i++) {
X[i] = ((uint32_t)in[4*i] << 24) | ((uint32_t)in[4*i+1] << 16) |
((uint32_t)in[4*i+2] << 8) | in[4*i+3];
}
/* 32轮迭代 */
for (int i = 0; i < 32; i++) {
X[i+4] = X[i] ^ T(X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i]);
}
/* 反序变换并输出 */
for (int i = 0; i < 4; i++) {
uint32_t val = X[35 - i];
out[4*i] = (val >> 24) & 0xFF;
out[4*i+1] = (val >> 16) & 0xFF;
out[4*i+2] = (val >> 8) & 0xFF;
out[4*i+3] = val & 0xFF;
}
}
/* 解密一个分组(轮密钥逆序使用) */
void sm4_decrypt_block(const uint32_t rk[32], const uint8_t *in, uint8_t *out) {
uint32_t rk_rev[32];
for (int i = 0; i < 32; i++) {
rk_rev[i] = rk[31 - i];
}
sm4_encrypt_block(rk_rev, in, out);
}
核心要点:
- 加密和解密共用同一个函数,只是轮密钥顺序相反
- S盒用查表法实现,速度最快
- 密钥扩展中的T'和加密用的T,区别只在L和L'
- CK参数可以预计算,避免每次加密都算一遍
3.6 工作模式实现要点
有了分组加密函数,工作模式就简单了。这里我只说几个关键点:
ECB模式:直接循环调用sm4_encrypt_block。注意数据长度必须是16字节的倍数,不足的需要填充。国密标准推荐PKCS7填充。
CBC模式:每个明文分组先与前一个密文分组异或,再加密。第一个分组与IV异或。解密时,解密后的数据与上一个密文分组异或。IV不需要保密,但每次加密都应该用新的随机IV。
CTR模式:构造计数器分组(通常16字节),加密后与明文异或。计数器分组一般由nonce(8字节)和分组序号(8字节)组成。解密和加密完全一样——都是加密计数器再异或。
我的建议:如果项目对性能要求高,优先用CTR模式。它天然支持并行,而且不需要填充。我在一个视频加密项目里用CTR模式,配合多线程,吞吐量比CBC快了将近4倍。