4. SM4 对称加密算法(下):工作模式、填充方式与多语言实现

上一讲我们把 SM4 的轮函数和密钥扩展拆开揉碎了讲。这一讲,咱们聊聊真正在工程里怎么用。

说实话,算法本身再漂亮,落地时选错工作模式或者填充方式,照样会出大问题。我见过不少项目,SM4 算法实现没问题,但就因为用了 ECB 模式处理结构化数据,结果被攻击者一眼看穿规律。嗯,这种坑咱们今天必须填上。

4.1 SM4 的四种核心工作模式

工作模式决定了「如何用同一个密钥加密多块数据」。SM4 的分组大小是 16 字节,但你要加密的数据不可能每次都刚好 16 字节。所以,模式的选择直接决定了安全性、性能和并行能力。

核心原则: 除了 ECB,其他三种模式都引入了「反馈机制」—— 前一个密文块会影响当前块的加密结果。这是对抗模式分析的关键。

4.1.1 ECB 模式(电子密码本模式)

ECB 是最简单的模式。每 16 字节独立加密,互不影响。

  • 优点: 可以并行加密/解密,速度快,出错只影响单个块。
  • 致命缺点: 相同的明文块 → 相同的密文块。这在加密图片、数据库字段时是灾难。
避坑指南: 我曾经在一个金融项目中看到有人用 ECB 加密用户身份证号。因为身份证号前 6 位是地区码,大量用户的前 6 位相同,密文前 16 字节完全一样。攻击者不需要解密,光看重复模式就能推断出数据规律。所以,ECB 只适合加密随机数据(如密钥本身),永远不要用它加密结构化数据。

4.1.2 CBC 模式(密码分组链接模式)

CBC 引入了「初始化向量(IV)」。每个明文块先与前一个密文块 XOR,再加密。第一个块与 IV 异或。

  • 优点: 相同的明文块会产生不同的密文块,安全性远高于 ECB。
  • 缺点: 加密必须串行(依赖前一个密文块),解密可以并行。IV 必须是不可预测的随机数。
我的习惯: IV 长度固定为 16 字节,每次加密都重新生成。IV 不需要保密,但绝对不能重复使用同一个 IV。我一般把 IV 直接拼在密文前面,解密时先取前 16 字节。

4.1.3 CTR 模式(计数器模式)

CTR 把分组密码变成了流密码。它加密一个递增的计数器值,得到密钥流,再与明文 XOR。

  • 优点: 加密和解密都可以完全并行,支持随机访问(可以只解密某一段数据)。
  • 缺点: 绝对不能重复使用相同的「密钥 + 计数器组合」。否则,两个密文 XOR 就能消掉密钥流,直接得到明文的 XOR。

我个人很喜欢 CTR 模式,尤其是在嵌入式设备上。因为它不需要填充,而且可以预先生成密钥流,加密时只需要做 XOR 操作,速度极快。

4.1.4 GCM 模式(伽罗瓦/计数器模式)

GCM 是目前最推荐的工作模式。它在 CTR 的基础上增加了认证标签(Authentication Tag),同时提供加密和完整性校验。

  • 优点: 加密 + 认证一步到位,能检测密文是否被篡改。支持附加认证数据(AAD),比如协议头。
  • 缺点: 实现复杂度稍高,需要硬件加速才能达到最佳性能。
工程建议: 如果你在 2024 年之后新起一个项目,默认选 GCM 模式。它同时解决了机密性、完整性和抗重放攻击的问题。我最近三个项目全部用的 GCM,没有出过任何安全问题。

模式对比速查表

模式 安全性 并行加密 并行解密 是否需要填充 是否提供认证
ECB 低(模式分析) 是 是 是 否
CBC 中(需随机 IV) 否 是 是 否
CTR 高(需唯一 nonce) 是 是 否 否
GCM 高(加密+认证) 是 是 否 是

4.2 填充方式:PKCS7 与 ZeroPadding

ECB 和 CBC 模式要求明文长度是 16 字节的整数倍。如果不够,就需要填充。

4.2.1 PKCS7 填充

PKCS7 是业界最通用的填充方式。规则很简单:缺几个字节,就填充几个值为「缺的字节数」的字节。

  • 如果明文正好是 16 的倍数,PKCS7 会额外填充一整块 16 个 0x10。
  • 解密时,读取最后一个字节的值 n,然后删除最后 n 个字节。
为什么必须填满一整块? 如果不填,解密时无法区分「最后一个字节是填充数据还是明文数据」。这是 PKCS7 设计的精妙之处。

4.2.2 ZeroPadding 填充

ZeroPadding 用 0x00 填充剩余字节。简单粗暴,但有个致命问题:如果明文末尾本身就有 0x00,解密后无法判断哪些 0x00 是填充、哪些是原始数据。

我曾经踩过的坑: 在解析二进制协议时用了 ZeroPadding,结果明文里恰好有 0x00 结尾的字段,解密后数据被错误截断。从那以后,我只有在确定明文末尾不会出现 0x00 的场景下才用 ZeroPadding,比如纯 ASCII 文本。其他情况一律用 PKCS7。

4.3 SM4 工作模式与填充关系图

下面这张图展示了不同工作模式如何处理数据块,以及填充在其中的位置。

SM4 工作模式与填充关系图 SM4 工作模式与填充关系图 原始数据(任意长度) 长度是否为 16 字节整数倍? 是 否 不需要填充(CTR/GCM) 需要填充(ECB/CBC) PKCS7 / ZeroPadding SM4 分组加密(每 16 字节) 密文输出(ECB/CBC 含填充,CTR/GCM 无填充)

4.4 多语言代码实现

理论说完了,直接上代码。我会用 Java、Python 和 Go 分别实现 SM4-CBC 和 SM4-GCM 模式。你想想看,实际项目中你大概率只需要这两种模式。

4.4.1 Java 实现(使用 Bouncy Castle)

Java 生态里,Bouncy Castle 是最成熟的国密支持库。我个人习惯用 org.bouncycastle.pqc.legacy.crypto.sm4 包。

import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.Security;

public class SM4Example {
    static {
        Security.addProvider(new BouncyCastleProvider());
    }

    // SM4-CBC 加密
    public static byte[] encryptCBC(byte[] key, byte[] iv, byte[] data) throws Exception {
        Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS7Padding", "BC");
        SecretKeySpec keySpec = new SecretKeySpec(key, "SM4");
        IvParameterSpec ivSpec = new IvParameterSpec(iv);
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
        return cipher.doFinal(data);
    }

    // SM4-GCM 加密(推荐)
    public static byte[] encryptGCM(byte[] key, byte[] nonce, byte[] data, byte[] aad) throws Exception {
        Cipher cipher = Cipher.getInstance("SM4/GCM/NoPadding", "BC");
        SecretKeySpec keySpec = new SecretKeySpec(key, "SM4");
        GCMParameterSpec gcmSpec = new GCMParameterSpec(128, nonce);
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
        cipher.updateAAD(aad);
        return cipher.doFinal(data);  // 密文 + 16 字节认证标签
    }
}
注意: GCM 的 nonce 推荐 12 字节(96 位),这是性能和安全的最佳平衡点。认证标签长度固定为 16 字节(128 位),不要为了省空间而缩短它。

4.4.2 Python 实现(使用 gmssl 库)

Python 下我推荐 gmssl 库,它是纯 Python 实现,兼容性很好。

from gmssl.sm4 import CryptSM4, SM4_CBC, SM4_GCM
import os

# SM4-CBC 加密
def sm4_cbc_encrypt(key: bytes, data: bytes) -> bytes:
    iv = os.urandom(16)
    crypt = CryptSM4()
    crypt.set_key(key, SM4_CBC)
    crypt.set_iv(iv)
    ciphertext = crypt.crypt_ecb(data)  # 注意:gmssl 的 CBC 接口叫 crypt_ecb
    return iv + ciphertext  # IV 拼在前面

# SM4-GCM 加密
def sm4_gcm_encrypt(key: bytes, data: bytes, aad: bytes = b'') -> bytes:
    nonce = os.urandom(12)
    crypt = CryptSM4()
    crypt.set_key(key, SM4_GCM)
    crypt.set_iv(nonce)
    crypt.set_aad(aad)
    ciphertext = crypt.crypt_gcm(data)
    tag = crypt.get_tag()
    return nonce + ciphertext + tag  # nonce + 密文 + 认证标签
我曾经踩过的坑: gmssl 的 CBC 接口命名有点迷惑,叫 crypt_ecb 但实际上处理的是 CBC 模式。一定要看文档确认。另外,Python 的 os.urandom 是安全的随机数生成器,不要用 random 模块生成 IV 或 nonce。

4.4.3 Go 实现(使用 tjfoc/gmsm 库)

Go 语言下,github.com/tjfoc/gmsm/sm4 是最主流的国密库。

package main

import (
    "crypto/rand"
    "github.com/tjfoc/gmsm/sm4"
)

// SM4-CBC 加密
func SM4CBCEncrypt(key, plaintext []byte) ([]byte, error) {
    iv := make([]byte, sm4.BlockSize)
    rand.Read(iv)
    
    ciphertext, err := sm4.Sm4Cbc(key, plaintext, iv, true) // true = 加密
    if err != nil {
        return nil, err
    }
    return append(iv, ciphertext...), nil
}

// SM4-GCM 加密
func SM4GCMEncrypt(key, plaintext, aad []byte) ([]byte, error) {
    nonce := make([]byte, 12)
    rand.Read(nonce)
    
    ciphertext, err := sm4.Sm4Gcm(key, nonce, plaintext, aad, nil, true)
    if err != nil {
        return nil, err
    }
    // ciphertext 包含密文 + 16 字节认证标签
    return append(nonce, ciphertext...), nil
}
工程要点: 三种语言的实现都遵循同一个模式:
1. 生成随机 IV/nonce(CBC 用 16 字节,GCM 用 12 字节)
2. 将 IV/nonce 拼在密文前面一起存储或传输
3. 解密时先取出 IV/nonce,再解密剩余部分
4. GCM 模式一定要验证认证标签,否则等于没用

4.5 实战中的选择建议

说了这么多,到底怎么选?我直接给你一个决策树:

  1. 新项目? 无脑选 SM4-GCM。加密 + 认证一步到位,省心。
  2. 需要兼容旧系统? 选 SM4-CBC + PKCS7。这是最广泛支持的组合。
  3. 嵌入式或硬件资源受限? 选 SM4-CTR。不需要填充,可以预计算密钥流。
  4. 加密随机数据(如密钥)? 可以用 SM4-ECB,但仅限此场景。
最后提醒: 无论选哪种模式,密钥管理永远是第一位的。SM4 算法本身再安全,密钥硬编码在代码里也是白搭。我见过太多「算法没问题,密钥泄露了」的案例。密钥请使用硬件安全模块(HSM)或密钥管理服务(KMS)存储。