SM4 加扰认证加密实战:Encrypt-then-MAC 实现与生产陷阱

密码学 · 2026-10-29 · 10 阅读

为什么需要 Encrypt-then-MAC

认证加密(AEAD)是数据安全的黄金标准——它同时保证机密性和完整性。GCM、ChaCha20-Poly1305 都是常见的 AEAD 模式,但问题在于:Python 标准库 cryptography 不支持 SM4-GCM。

Tongsuo(BabaSSL)有 sm4-gcm,但没有 Python 绑定;gmssl 库支持 sm4_cbc_encrypt 和 hmac_sm3,但分开调用。生产环境下,直接用 CBC 加密然后手动拼接 MAC 是危险陷阱,顺序错了就是灾难。

正确做法是 Encrypt-then-MAC(EtM)——先加密,再对密文计算 HMAC,验证时先验后解。这是 NIST SP 800-38D 和 IETF RFC 5116 推荐的组合方式,密码学强度等价于 AEAD。

一、方案架构

CODE
加密:明文 → [SM4-CBC] → 密文 → [HMAC-SM3] → (密文 || tag)
解密:(密文 || tag) → [HMAC-SM3 验证] → 若通过 → [SM4-CBC 解密] → 明文

两个密钥独立派生,绝不共用:

  • enc_key:SM4-128 密钥(16 字节)
  • mac_key:HMAC-SM3 密钥(32 字节,SM3 输出长度)
派生方式统一用 HKDF-SM3:
关键细节:HKDF 的 length 参数必须 ≥ 16+32=48,SM3 输出 32 字节,HKDF-Expand 会递归计算,48 字节完全在 SM3 输出范围内,不会触发额外的哈希轮次。

二、完整实现

三、输出格式详解

加密输出 salt || iv || ciphertext || tag,共 16 + 16 + ceil(n/16)*16 + 32 字节:

字段长度说明
salt16 字节HKDF 盐值,每次随机,用于密钥派生
iv16 字节SM4-CBC 初始化向量,每次随机
ciphertext16×⌈n/16⌉ 字节PKCS7 填充后的密文,≥ 原始长度
tag32 字节HMAC-SM3 完整输出,不可截断
存储或传输时整个 blob 可直接序列化(base64 或 hex),无需额外分隔符,因为长度结构固定。

四、生产环境五个陷阱

陷阱 1:不要用 gmssl 的 sm3_hmac 代替 cryptography 的 HMAC

gmssl 的 sm3_hmac(key, msg) 返回的是 Python bytes,但不能与 cryptography.hmac.HMAC 互操作。混合使用两套库计算同一消息会得到不同结果,因为 gmssl 内部使用了不同的填充或缓冲策略。

正确做法:全程用 cryptography.hmac.HMAC + hashes.SM3(),只在 SM4 加密步骤用 gmssl(因为 cryptography 原生支持 SM4-CBC)。

陷阱 2:IV 必须每加密一次重新随机

很多开发者误以为 IV 可以复用(和密钥一样存起来)。CBC 模式下 IV 重用等于明文模式泄露——相同明文 + 相同 IV 产生相同密文前缀,攻击者可以通过比较密文推断数据相似性。

每次 encrypt() 调用都必须调用 os.urandom(16) 生成新 IV,IV 本身(16 字节)与密文一起存储,不需要保密。

陷阱 3:永远不要先解密再验证 MAC

EtM 的核心安全假设是:验证失败时不返回任何解密中间状态。如果先解密再验证(MAC-then-ENCRYPT 或 ENCRYPT-then-DECRYPT),攻击者可以发送篡改过的密文,利用解密失败的时序差异或错误信息判断密文结构,这是经典的 padding oracle 攻击入口。

本实现的 decrypt() 严格先验后解,hmac.compare_digest() 是常数时间比较,不泄露 tag 匹配进度。

陷阱 4:PKCS7 填充的边界情况

当明文长度恰好是 16 字节的整数倍时,PKCS7 会添加一整块填充(16 字节 0x10),这不是 bug 而是标准规定。解密时 _pkcs7_unpad 必须检查最后一个字节是否等于填充长度,以及所有填充字节是否一致——否则攻击者可以构造非法填充绕过验证。

本实现在 _pkcs7_unpad 中同时检查了 pad_len 范围(1-16)和字节值一致性,双重防御。

陷阱 5:master_key 不要直接用用户密码

HKDF 的输入应该是高熵密钥,不是用户密码。密码需要先用 PBKDF2-HMAC-SM3 或 Argon2id 派生。本模块假设调用方传入的是 32 字节以上的高熵 master_key——如果你要从用户密码派生,请在调用 encrypt() 之前先做 KDF:

PYTHON
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
password = b"my-str0ng-p@ssw0rd"
salt = os.urandom(16)
kdf = PBKDF2HMAC(algorithm=hashes.SM3(), length=32, salt=salt, iterations=600000)
master_key = kdf.derive(password)

NIST SP 800-132 推荐 PBKDF2 最少 1000 次迭代,SM3 计算速度比 SHA-256 略慢,建议 600000 次以上以抵御暴力破解。

五、与 SM4-GCM 的对比

维度SM4-GCM(理想)SM4-CBC + HMAC-SM3(本方案)
认证强度同等(AEAD)同等(EtM 安全证明成立)
Python 原生支持❌ 不可用✅ cryptography + gmssl
性能(单线程)GCM 硬件加速最快CBC 软件约 200-400 MB/s
并行加密GCM 可并行CBC 串行(但 MAC 可并行计算)
实现复杂度低(单 API 调用)中(两步操作,需手动打包)
生产可用需 Tongsuo/BabaSSL SDK✅ 标准库即出即用的方案
对于不使用硬件加速的软件场景,EtM 方案的性能损失通常在 10-20% 以内,安全强度无损。如果你的业务在容器或云环境运行(无 AES-NI/SM4 硬件指令),这个差距完全可以接受。

六、相关实践