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位。具体过程:
- 把密钥拆成4个32位字:K0, K1, K2, K3。
- 加一个固定系统参数FK,做异或和变换。
- 通过32轮迭代,每轮生成一个轮密钥rk,公式:rk[i] = K[i+4] = K[i] ⊕ T'(K[i+1] ⊕ K[i+2] ⊕ K[i+3] ⊕ CK[i])。
- 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做批量数据加密,密钥扩展和32轮迭代是底子,CBC/CTR是应用层模式。微服务里CBC适合小数据安全要求高,CTR适合大数据高并发。ECB能免则免,别给自己挖坑。