4. SM4 对称加密算法原理与 Java 实现

SM4 国密算法,说白了,就是咱们国家自己的 AES。加密强度够用,性能也不错。我最早接触它是在一个金融项目里,那时候国产化刚起步,踩了不少坑,今天咱们就把核心的 S 盒、密钥扩展和轮函数掰开揉碎讲清楚。

4.1 S 盒:算法的灵魂

SM4 的 S 盒是一个 8 位输入、8 位输出的非线性置换表。你想想看,整个算法的安全性,一大半都靠它撑着。

我第一次看到这个 S 盒的时候,总觉得它就是 256 个随机数。后来仔细研究才发现,它是基于有限域 GF(2^8) 上的仿射变换和逆映射构造的,有严格的数学背景。

我的习惯: 在项目里,我通常会把这 256 个字节预编译成静态数组。运行时查表比现场计算快得多,毕竟查表就是个 O(1) 的内存访问。
// S盒表,长度256,对应0x00~0xFF的每个输入
public static final int[] S_BOX = new int[] {
    0xD6, 0x90, 0xE9, 0xFE, 0xCC, 0xE1, 0x3D, 0xB7,
    0x16, 0xB6, 0x14, 0xC2, 0x28, 0xFB, 0x2C, 0x05,
    // ... 剩下240个字节(实际256个)
    0xB3, 0xE5, 0x40, 0x79, 0x6B
};

这里有个坑:S 盒的输入是 8 位(0-255),但 Java 的 byte 是带符号的。如果你直接拿 byte 值当数组索引,负索引会报 ArrayIndexOutOfBoundsException。

注意: 查表时务必用 (byteValue & 0xFF) 转成无符号整数。我曾经在项目里吃过这个亏,上线后诡异的数组越界,查了两天才找到原因。

4.2 密钥扩展:从 128 位到 32 轮

SM4 的主密钥是 128 位,但加密需要 32 个轮密钥,每个轮密钥 32 位。怎么变出来的?密钥扩展算法。

咱们先看固定参数。系统参数 FK(四个 32 位常数),用来初始化密钥状态:

// FK 常数(十六进制)
int[] FK = {0xA3B1BAC6, 0x56AA3350, 0x677D9197, 0xB27022DC};

还有固定参数 CK(32 个 32 位常数),每一轮选一个,用来打乱密钥中间态。CK 的计算公式是 ck = (i * 7) % 256,国内算法标准里已经算好了。

// 密钥扩展核心逻辑
private static int[] keyExpansion(byte[] key) {
    int[] MK = bytesToInts(key); // 转为4个32位字
    int[] K = new int[4 + 32];
    int[] rk = new int[32];

    // 初始化:MK 异或 FK
    K[0] = MK[0] ^ FK[0];
    K[1] = MK[1] ^ FK[1];
    K[2] = MK[2] ^ FK[2];
    K[3] = MK[3] ^ FK[3];

    // 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];
    }
    return rk;
}

这里的关键是 T_prime 变换。它先对输入的 32 位字做 4 次 S 盒置换,再做一个线性变换 L'。

T_prime 与加密的 T 有什么不同?

  • 密钥扩展的 L' 是:B ^ (B <<< 13) ^ (B <<< 23)
  • 加密轮函数的 L 是:B ^ (B <<< 2) ^ (B <<< 10) ^ (B <<< 18) ^ (B <<< 24)
  • 线性变换不同,安全性目标也不一样。

4.3 加密轮函数

加密流程分了 32 轮,每一轮就干三件事:异或、S 盒替换、线性变换。

举个例子,第 i 轮,我有四个 32 位字:(Xi, Xi+1, Xi+2, Xi+3),再加上轮密钥 rki。计算过程如下:

// 第 i 轮的加密函数
private int roundFunction(int x0, int x1, int x2, int x3, int rk) {
    int temp = x1 ^ x2 ^ x3 ^ rk;    // 第一步:异或
    int sBoxResult = sBoxTransform(temp);  // 第二步:S盒变换
    int linearResult = linearL(sBoxResult); // 第三步:线性变换L
    return x0 ^ linearResult;           // 输出
}

// S盒变换:将32位字拆成4个字节,分别查表
private int sBoxTransform(int input) {
    int[] result = new int[4];
    for (int i = 0; i < 4; i++) {
        int byteVal = (input >> (8 * (3 - i))) & 0xFF;
        result[i] = S_BOX[byteVal];
    }
    return (result[0] << 24) | (result[1] << 16) |
           (result[2] << 8) | result[3];
}

// 线性变换 L
private int linearL(int input) {
    return input ^ rotl(input, 2) ^ rotl(input, 10) ^
           rotl(input, 18) ^ rotl(input, 24);
}

32 轮跑完以后,把最后四个字反序输出就是密文。嗯,这里要注意,SM4 用的是反序输出,不是直接输出。

4.4 解密:把轮密钥倒过来用

解密比加密更简单——你想想看,它就是加密的逆过程。

具体做法:加密时 rk0 到 rk31 依次用,解密时 rk31 到 rk0 逆序用。其他一模一样,甚至连轮函数都不用改。

// 加密:顺序使用轮密钥
int[] rk = keyExpansion(key);
byte[] cipher = encrypt(plainText, rk);

// 解密:逆序使用轮密钥
byte[] decrypted = encrypt(cipher, reverseArray(rk));

为什么会这样?因为 SM4 的加密和解密轮函数是自逆的。说白了,同样的硬件和代码,换一下密钥顺序就能同时做加解密,特别适合嵌入式场景。

经验之谈: 我在一个物联网项目里把 SM4 跑在 Cortex-M3 上,密钥扩展一次,然后加密和解密共用一个函数,省了不少代码量。你如果也搞嵌入式,这个特性值得利用。

4.5 知识结构总览

下面是本章的知识图谱,SM4 算法的核心脉络一目了然:

SM4 对称加密算法核心流程 128位主密钥 MK 密钥扩展算法 FK / CK 常数 32个轮密钥 rk[0..31] 加密轮函数 (32轮) X_i+1 ^ X_i+2 ^ X_i+3 ^ rk_i → S盒(τ) → 线性变换L → X_i+4 解密轮函数 (32轮) 逆序使用轮密钥 函数体与加密完全一致 轮密钥反向 明文 / 密文 (128位) 注:S盒为固定256字节静态数组

4.6 Java 完整实现要点

总结一下 Java 实现中的几个关键点:

模块 核心方法/数据结构 注意事项
S 盒 static int[] S_BOX = { ... } 查表时 byte & 0xFF 防负索引
密钥扩展 keyExpansion(byte[] key) FK 初始化 + 32 轮 T' 迭代
加密轮函数 roundFunction(x0,x1,x2,x3, rk) 异或 → S盒 → 线性 L → 输出异或
解密 加密函数 + 逆序轮密钥 函数体完全复用
循环移位 rotl(int x, int bits) 用 (x << bits) | (x >>> (32 - bits))

性能小贴士: 我在生产环境里常用的优化手段:

  • S 盒查表尽量用局部变量,减少数组边界检查
  • 32 轮循环可以手工展开,减少循环开销
  • 密钥扩展一次后缓存,加解密复用

嗯,SM4 的算法原理和实现就这些了。S 盒、密钥扩展、轮函数三件事串起来,就能搭起一套完整的加解密引擎。你自己动手实现一遍,比看十遍文章都管用。