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、密钥扩展算法

下面这张图是我当年给团队培训时画的,现在看依然清晰:

SM4算法总体结构 128位明文分组 初始白化(可选) 32轮Feistel迭代(i=0..31) 轮函数 F(X[i], X[i+1], X[i+2], X[i+3], rk[i]) 合成置换 T = τ ∘ L τ:4个S盒 L:循环移位+异或 反序变换 R 128位密文分组 密钥扩展独立进行 生成32个轮密钥 rk[0..31]

加密过程可以概括为:先把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'。

具体流程是这样的:

  1. 把128位主密钥MK分成4个32位字 (MK0, MK1, MK2, MK3)
  2. 用系统参数FK做异或初始化:(K0, K1, K2, K3) = (MK0⊕FK0, MK1⊕FK1, MK2⊕FK2, MK3⊕FK3)
  3. 对 i=0..31,计算:K[i+4] = K[i] ⊕ T'(K[i+1]⊕K[i+2]⊕K[i+3]⊕CK[i])
  4. 轮密钥 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倍。