4. SM4对称加密算法:分组模式、密钥扩展与CBC/ECB/CTR实践

4.1 为什么微服务里还得用SM4?

先聊个实际的。我在做金融核心系统迁移时,数据量动辄几百万条,如果用国密里的SM2非对称加密去加解密每条数据,性能根本扛不住。SM4正好填补这个空缺——它是对称算法,密钥长度128位,加解密速度是SM2的几百倍。说白了,SM4负责“量大管饱”的场景,比如数据库敏感字段加密、配置文件加密、服务间消息体的保护。

SM4是我国官方标准(GB/T 32907-2016),结构上跟AES有点像,但轮数、S盒、密钥扩展算法都是独立设计的。我个人习惯把SM4叫做“国密界的AES”,但千万别直接当AES用——它们的实现细节差很多。

4.2 算法核心:分组与密钥扩展

SM4是分组密码,一次处理128位(16字节)。密钥也是128位。我刚开始学的时候老搞混:“SM4的密钥128位,跟AES一样,那轮数呢?”SM4用了32轮迭代,比AES-128(10轮)多了不少,但每轮的运算量小,整体速度依然很快。

4.2.1 密钥扩展(Key Expansion)

密钥扩展把128位密钥变成32个轮密钥(round key),每个32位。具体过程:

  1. 把密钥拆成4个32位字:K0, K1, K2, K3。
  2. 加一个固定系统参数FK,做异或和变换。
  3. 通过32轮迭代,每轮生成一个轮密钥rk,公式:rk[i] = K[i+4] = K[i] ⊕ T'(K[i+1] ⊕ K[i+2] ⊕ K[i+3] ⊕ CK[i])。
  4. T'是一个非线性变换,里面包含了S盒、循环移位。

这里有个坑,我曾经踩过:SM4的密钥扩展用的是“非线性反馈移位寄存器”的思路,但很多人直接把轮密钥当固定表存起来。如果你在硬件资源受限的嵌入式设备上做,实时计算密钥扩展比查表更省内存。嗯,我建议根据场景选:做微服务网关时,用查表;做IoT设备时,用实时计算。

4.2.2 加解密流程

加密:明文128位分成4个32位字(X0, X1, X2, X3),然后进行32轮迭代。每轮用一个轮密钥rk,经过F函数(含S盒、线性变换L)得到新的状态。最后把4个字反转输出。

解密:跟加密几乎一样,只是轮密钥的使用顺序反过来——把rk0到rk31逆序用就行。这一点跟AES不同,AES解密需要单独写逆变换,而SM4直接用反序轮密钥复用加密函数,实现起来特别省事。

关键结论:SM4加密和解密可用同一套代码,只改轮密钥顺序。这一点在微服务里很有用,比如你写一个通用的加解密工具类,只需要传一个参数指明是加密还是解密。

4.3 分组模式:ECB、CBC、CTR

SM4本身是分组密码,一次只能加密16字节。实际要加密长数据,必须上分组模式。我选型时主要看三个:ECB、CBC、CTR。

模式 特点 是否推荐 典型场景
ECB 每个16字节独立加密;相同明文块产生相同密文块 不推荐(除非无意义数据) 仅用于密钥保护等极少数场景
CBC 每个块跟上一个密文块异或后加密;需要IV 推荐 数据完整性要求高,可配合HMAC
CTR 将计数器加密后与明文异或;可并行计算 推荐(高性能场景) 高并发、长流数据、消息队列

4.3.1 ECB——能不用就别用

为什么?你看两段相同的明文,ECB会输出相同的密文。这相当于给攻击者暴露了数据的统计模式。我在某次安全审计时发现一个老系统用ECB加密身份证号,结果攻击者根据密文重复就能猜出哪些人是同一个地方出生……幸好后来改成CBC了。所以,我的原则:只要数据有规律,ECB不用。

4.3.2 CBC——经典模式

CBC需要初始向量IV,IV必须是随机的且每次不一样。加密过程串行,每个块依赖前一个密文块。解密时可以并行(因为知道所有密文块)。

我用CBC的场景:数据库列级加密,比如加密用户的手机号。因为数据量不大,甚至一次只加密几条,串行的性能损失可以忽略。但是CBC的一个弱点:如果IV重复或者被攻击者控制,安全性下降。我曾经在项目中生成IV时用了时间戳+计数器,结果测试环境环境重启后IV重复,排查了好久才发现是IV生成逻辑不够随机。现在我的习惯:直接用系统随机数生成16字节IV,每次加密都生成新的。

4.3.3 CTR——高并发首选

CTR把分组密码变成了流密码。它加密的其实是计数器(nonce+计数器值),然后跟明文逐字节异或。好处:加密和解密完全一样(都是加密计数器,然后异或),而且可以并行——多个核同时算不同的计数块。

我在微服务间RPC消息加密时特别喜欢用CTR。比如一个服务要加密10MB的protobuf数据,按16KB分片,每个分片分配不同的计数器值,8个线程并行加密,速度能快近8倍。而且CTR不需要填充(padding),省掉很多麻烦。

小技巧:CTR的nonce(通常用12字节)加上计数器(4字节)组成16字节输入。nonce最好每次都换,接口设计时让调用方传一个随机数,或者服务端生成后放在密文前面一起传输。

4.4 实践:Python+SM4实现CBC/CTR

我用Python举例。纯Python实现SM4性能一般,生产环境推荐用C扩展库如gmssl或pycryptodomex。这里展示接口设计思路。

from gmssl.sm4 import CryptSM4, SM4_ENCRYPT, SM4_DECRYPT
import os

def sm4_cbc_encrypt(key, plaintext, iv=None):
    '''CBC模式加密,自动生成IV并返回'''
    if iv is None:
        iv = os.urandom(16)
    cryptor = CryptSM4(padding_mode=1)  # PKCS7 padding
    cryptor.set_key(key, SM4_ENCRYPT)
    ciphertext = cryptor.crypt_cbc(iv, plaintext)
    return iv + ciphertext  # 把IV拼接在密文前

def sm4_cbc_decrypt(key, data):
    '''CBC解密,从密文前16字节取出IV'''
    iv = data[:16]
    ciphertext = data[16:]
    cryptor = CryptSM4(padding_mode=1)
    cryptor.set_key(key, SM4_DECRYPT)
    return cryptor.crypt_cbc(iv, ciphertext)

def sm4_ctr_encrypt(key, plaintext, nonce=None):
    '''CTR模式,nonce默认随机12字节'''
    if nonce is None:
        nonce = os.urandom(12)
    # 注意:CTR不需要padding
    cryptor = CryptSM4(mode=0)  # mode=0表示CTR,具体看库文档
    # 此处省略详细API调用,核心思路:crypto_stream_xor
    # 伪代码:ciphertext = sm4_ctr_xor(key, nonce, plaintext)
    return nonce + ciphertext

def sm4_ctr_decrypt(key, data):
    nonce = data[:16]  # 假设12字节nonce+4字节计数器初始值
    ciphertext = data[16:]
    # 解密跟加密完全一样
    # plaintext = sm4_ctr_xor(key, nonce, ciphertext)
    return plaintext

实际微服务集成时,我会把加密函数封装成装饰器或中间件。例如Django中,对模型的某个字段自动加解密。

4.5 避坑指南

⚠️ 我曾经踩过的坑:

  • 密钥管理:不要把SM4密钥硬编码在代码里。用配置中心或密钥管理系统(KMS),每次服务启动时拉取。
  • IV/Nonce重复:CBC用随机IV,CTR用随机Nonce。不要用固定值,连0也不行。
  • 填充模式:ECB和CBC需要padding,CTR不需要。如果你用CBC但忘记带padding,数据长度不对齐会报错。我习惯统一用PKCS7。
  • 性能测试:SM4在Python中纯实现很慢(约50 MB/s),务必用C扩展或在Golang/Java中调用原生库。微服务网关可以考虑用硬件加速卡。

4.6 知识体系总览

下面这张图把SM4的核心脉络串起来了,方便你快速回忆。

SM4 对称加密核心脉络 128位明文 + 128位密钥 密钥扩展 → 32个轮密钥 32轮迭代:S盒 + 线性变换L 每轮用一个轮密钥rk[i] 分组模式:ECB / CBC / CTR ECB:独立加密(不推荐) CBC:前块依赖 + IV CTR:计数器 + 异或

一句话总结:SM4做批量数据加密,密钥扩展和32轮迭代是底子,CBC/CTR是应用层模式。微服务里CBC适合小数据安全要求高,CTR适合大数据高并发。ECB能免则免,别给自己挖坑。