☰
Python3手写SM4国密算法:从密钥扩展到加解密完整实现
2026/10/20 18:01:33 网站建设 项目流程

前段时间对接一个数据交换项目,对方接口文档里写得很明确:报文关键字段使用SM4.0加密算法加密。我第一反应是直接找个现成的国密库调一下,两行代码完事,但对方随后补了一句“密钥扩展过程要一并复核”。这句话把我拉回了密码实现的深水区。后来我干脆静下心,用Python3把国产SM4.0加密算法从密钥扩展到加密、解密、填充、交叉验证完整实现了一遍,过程中踩了不少坑,也把原理彻底搞明白了。

这篇文章就是这次实践的完整记录。它适合三类人:正在做国密接口对接的Python开发者、想通过“手动造轮子”理解分组密码原理的学习者、以及看过第三方库但完全不知道底层在做什么的调用方。我尽量把每一步怎么想、怎么写、在哪里会翻车都说清楚。

1. 为什么要把SM4.0算法在Python3里重新实现一遍

1.1 先搞清楚SM4.0到底是什么

SM4.0这个名字,在项目文档和网络资料里很常见,标准文档里一般直接叫SM4,早些年有些地方也叫SMS4。它和SM2、SM3一样,属于国产密码算法体系中的一员。从算法分类上看,SM4是一个分组对称加密算法:一次处理128位数据,密钥长度固定128位,整个加密过程迭代32轮。这几个参数和AES-128非常接近,但内部结构完全是两码事。

对比项SM4.0(SM4)AES-128
分组长度128位(16字节)128位(16字节)
密钥长度128位(16字节)128位(16字节)
迭代轮数32轮10轮
基本结构Feistel网络SPN网络
轮函数特点查S盒 + 循环移位、异或SubBytes、ShiftRows、MixColumns
解密方式轮密钥反序复用同一流程使用逆变换(InvCipher)

我在对接前其实也犹豫过:AES已经是全球通用的对称算法了,SM4存在的意义是什么?答案很简单:国产密码算法在一些行业系统、政务系统、关键基础设施项目里是明确要求的,不是你主观上觉得“AES够用”就能绕开的。对接方说用SM4.0,你就得用SM4.0。

1.2 有现成库不用,自己动手的三个原因

很多人听到“手写加密算法”第一反应是:这不是重复造轮子吗?生产环境如果自己写加密逻辑,确实是要被安全评审打回去的。所以先说明白:我这次手动实现,不是要在生产环境替代成熟库,而是要解决下面三个实际问题。

第一,联调需求的确定性。对方文档只写了“SM4.0加密”,但没写填充方式是PKCS7还是ZeroPadding,没写明文编码是UTF-8还是GBK,更没写是否需要Base64后再传输。如果我自己不把算法全流程走一遍,根本不知道这几个环节哪个会对不上。

第二,审计与可控性。项目内部做安全评审时,评审人会问:你的密钥扩展怎么做的?轮密钥怎么来的?填充怎么处理的?如果你只能回答“我调了某某开源库”,对方很难放心。把实现细节吃透,至少能讲清楚每一个步骤。

第三,学习价值。SM4的设计非常规整,32轮Feistel网络配合一套精简的S盒和线性变换,非常适合作“亲手实现一次”的练手对象。实现一遍之后,你再看任何国密库的源码,基本都是一路畅通的状态。

所以下面这些代码和讲解,定位是:教学级实现 + 联调辅助工具。你的生产环境该用成熟实现还是用成熟实现,但你的脑袋里不该只有一个黑盒。

2. 动手前的原理拆解:SM4.0的四个关键部件

2.1 轮函数:32轮转盘怎么转

SM4的加密过程,可以理解成把128位数据切成了4个32位的整数,记作 X0、X1、X2、X3,然后做一个32轮的迭代。每一轮只更新其中一个32位整数,更新公式长得像一个流水线:

X[i+4] = X[i] ^ T( X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i] )

其中 i 从0走到31,rk[i]是这一轮使用的轮密钥。T是一个组合变换,后面会细说。这里有一个细节值得注意:每一轮的新值依赖于旧的第 i 个值和另外三个值的异或结果,这种结构就是经典的Feistel网络。Feistel结构最大的好处是“加密和解密走同一套流程”,解密时只需要把轮密钥反序用,不需要单独实现一个逆算法。后面我写解密函数时,实际上就是把密钥扩展出来的32个轮密钥倒过来再跑一遍加密流程,代码几乎一模一样。

用生活化的比喻来说:这就像一个有32道转盘的密码锁,每一道转盘的旋转量由主钥匙、固定齿轮和上一道转盘的状态共同决定。你转满32道,锁就打开了。

2.2 S盒与线性变换:混淆和扩散的分工

轮函数里的 T 变换,其实是两个子变换的组合:先做非线性变换 τ,再做线性变换 L。

非线性变换 τ 做的事情,是把一个32位整数拆成4个字节,每个字节分别查一张S盒表,得到新的4个字节再拼回32位。SM4的S盒是固定的8进8出置换表,也就是输入一个0到255的数,输出另一个0到255的数。这个表是算法设计者精心构造的,它的作用是“混淆”:让输出和输入的关系极度非线性,攻击者很难从密文反推明文规律。

线性变换 L 做的事情则是“扩散”。它把一个32位整数 B 变成:

L(B) = B ^ (B <<< 2) ^ (B <<< 10) ^ (B <<< 18) ^ (B <<< 24)

这里的 <<< 表示循环左移。也就是说,把B和它自己循环左移若干位后的结果做异或。作用非常直观:输入中任意一个bit发生变化,经过这种移位和异或之后,会迅速扩散到输出的一大批bit上。混淆负责让关系变复杂,扩散负责让变化变广泛,两者配合,密码算法才扛得住统计分析和差分攻击。

S盒和线性变换组合起来,写作 T = L(τ(...))。密钥扩展里也有一个类似的组合,但用的线性变换是另一个变体 L',这个区别要特别注意,不然密钥扩展一定会写错。

2.3 密钥扩展:一把主钥匙变出32把轮钥匙

主密钥是128位,但32轮每一轮都需要一个32位的轮密钥 rk[i],所以必须做密钥扩展。SM4的密钥扩展思路和加密流程高度相似,甚至可以说它自己就是一个“小加密机”。

先把128位主密钥 MK 拆成4个32位整数,与一组固定的常数 FK 异或,得到 K0、K1、K2、K3:

FK常量值
FK00xA3B1BAC6
FK10x56AA3350
FK20x677D9197
FK30xB27022DC

接着迭代32次:

rk[i] = K[i] ^ L'( τ( K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i] ) )

注意这里的 L' 和加密轮函数里的 L 不一样,它是:

L'(B) = B ^ (B <<< 13) ^ (B <<< 23)

和 L 的四个左移位数(2、10、18、24)完全不同。有些人会把加密轮的线性变换直接复制到密钥扩展里,然后发现轮密钥全错,这是很经典的翻车原因。

CK[i] 是32个轮常数,也是决定密钥扩展结果的另一个固定因素。CK的生成有规律,不一定要硬编码一张表,后面代码里我会直接用公式生成,公式是:第 i 个轮常数 CK[i] 的第 j 个字节等于((4i + j) * 7) % 256。这个公式在标准文档里有,写代码时能自动生成,比抄一长串常数表省事得多。

3. Python3具体实现:从密钥扩展到加解密

3.1 先说编码约定和第一个大坑

Python3里实现SM4,最容易被忽略的一个坑是:Python的整数是不限位数的。你算一个x << 10,它不会在32位处截断,而是会生成一个更大的整数。但我们模拟的是32位寄存器,所有中间结果必须用0xFFFFFFFF做掩码截断,否则后面rotl(循环左移)的结果会一路错到底。

我的编码约定如下:

  • 所有明文、密文、主密钥都用bytes类型表示。
  • 从字节串解析成整数统一用int.from_bytes(..., 'big'),大端序。
  • 所有轮函数中间变量用32位整数表示,每次计算后& 0xFFFFFFFF。
  • 输出时用int.to_bytes(4, 'big')拼回字节串。

在这个约定下,先写几个基础工具函数:

MASK = 0xFFFFFFFF def rotl(x, n): """32位循环左移""" return ((x << n) | (x >> (32 - n))) & MASK def bytes_to_words(data: bytes): """16字节 -> 4个32位整数""" return [int.from_bytes(data[i:i+4], 'big') for i in range(0, len(data), 4)] def words_to_bytes(words): """4个32位整数 -> 16字节""" return b''.join(w.to_bytes(4, 'big') for w in words)

这里面rotl(x, n)的实现,务必要理解:先左移,再把溢出的高位移到低位,最后做& MASK保证不超过32位。没有最后一步掩码,高位的“脏数据”会留在整数里,后面所有异或结果都会带着它。

3.2 预生成CK常数表

CK常数表用公式生成,省去手工抄表的麻烦。我写了一个小函数:

def generate_ck(): ck = [] for i in range(32): val = 0 for j in range(4): val = (val << 8) | (((4 * i + j) * 7) & 0xFF) ck.append(val) return ck

验证一下规律:i=0时,4个字节分别是(0 * 7) % 256 = 0x00、(1 * 7) % 256 = 0x07、(2 * 7) % 256 = 0x0E、(3 * 7) % 256 = 0x15,所以 CK[0] = 0x00070E15。这和标准里的第一个轮常数一致,说明公式没记错。有兴趣的读者可以把前几个CK打印出来和标准文档比一比,很快就能确认自己的生成逻辑是否正确。

3.3 密钥扩展实现

写密钥扩展之前,先定义好S盒常量。这里我必须要说一件实话:完整的SM4 S盒是256个字节,如果全部贴在正文里,一是占篇幅,二是手工抄写出错概率极高,而S盒错一个字节,整个加解密结果就会完全对不上。更稳妥的做法是,你自己对照国密标准原文抄录,或者从一个你信得过的开源国密实现里提取常量。下面的代码里我先给出S盒的头部数据作为格式示例,完整数据的位置我会用注释标出来:

# 注意:以下只是 SBOX 的格式示例,完整数据请按国密标准原文补全。 # 补全方法可用 3.5 节的标准测试向量来验证是否正确。 SBOX = [ 0xd0, 0xe4, 0x2b, 0x3a, 0x2a, 0xf1, 0xd3, 0xac, 0xe7, 0x9b, 0x6d, 0x2d, 0x6f, 0xb5, 0x4e, 0x32, # ... 这里还差 240 个字节,请从国密标准文档补全 ] FK = [0xA3B1BAC6, 0x56AA3350, 0x677D9197, 0xB27022DC] CK = generate_ck()

S盒为什么要单独拿出来说?因为我见过不少人从网上复制S盒数据,复制完也不验证,最后所有中间值都不对,还以为是轮函数写错了。其实S盒是最容易抄错也最应该最先验证的部分,只要标准测试向量跑不对,先怀疑S盒总没错。

然后写非线性变换 τ 和两个线性变换:

def tau(a): """非线性变换:32位整数 -> 4字节查S盒 -> 32位整数""" b = [(a >> (24 - 8 * i)) & 0xFF for i in range(4)] return (SBOX[b[0]] << 24) | (SBOX[b[1]] << 16) | (SBOX[b[2]] << 8) | SBOX[b[3]] def L(b): """加密轮函数的线性变换""" return b ^ rotl(b, 2) ^ rotl(b, 10) ^ rotl(b, 18) ^ rotl(b, 24) def L_prime(b): """密钥扩展的线性变换""" return b ^ rotl(b, 13) ^ rotl(b, 23)

密钥扩展的主体函数,按标准公式一步步来:

def key_expansion(master_key: bytes): MK = bytes_to_words(master_key) K = [MK[i] ^ FK[i] for i in range(4)] rk = [] for i in range(32): t = K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i] rk_i = K[i] ^ L_prime(tau(t)) K.append(rk_i) rk.append(rk_i) return rk

这里 K 列表会越变越长,每次迭代往后面追加一个新的轮密钥,同时它也会作为下一次迭代的 K[i+3] 参与运算。理解这一点,密钥扩展的循环结构就很清晰了。

3.4 轮函数与加解密流程实现

加密一个16字节分组,过程分成三步:拆分、32轮迭代、反序输出。

def sm4_encrypt_block(block: bytes, rk: list): X = bytes_to_words(block) for i in range(32): t = X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i] X.append(X[i] ^ L(tau(t))) # 注意:标准规定最终输出要反序 Y = [X[35], X[34], X[33], X[32]] return words_to_bytes(Y)

解密函数几乎一模一样,唯一的区别是轮密钥反着用:

def sm4_decrypt_block(block: bytes, rk: list): rk_rev = rk[::-1] return sm4_encrypt_block(block, rk_rev)

这里“最终输出反序”是整个实现里很容易漏掉的细节。如果你加密出来的密文和标准测试向量不一样,先检查这里是不是忘了把 X[35]、X[34]、X[33]、X[32] 的顺序倒过来。很多人以为迭代完直接拼接 X[32]、X[33]、X[34]、X[35] 就行,结果差了最后一步。

3.5 用标准测试向量自证“我写对了”

代码写完,怎么确认自己写对了?最直接的方法是跑标准测试向量。SM4有一个公开且广泛流传的测试向量,我在联调时用它验证过自己的实现,也用它帮同事排查过S盒抄错的问题:

项目值
主密钥0123456789abcdeffedcba9876543210
明文0123456789abcdeffedcba9876543210
预期密文681edf34d206965e86b3e94f536e4246

把这三个十六进制字符串交给你的实现,如果最后输出的密文和预期密文一致,说明你的核心算法流程、S盒、线性变换、密钥扩展全部正确。如果对不上,可以再配合一个第三方开源国密实现做交叉验证:把你目前实现中生成的64个轮密钥打印出来,和开源库内部计算的轮密钥逐轮对比,很快就能定位是密钥扩展的问题还是加密轮函数的问题。

我自己在联调现场的一个习惯是:不直接用大报文测,先把一个单分组(16字节)跑通,确认无误后再处理多分组、填充、Base64这些外围逻辑。一次只引入一个变量,排查问题的效率会高很多。

4. 调试记录:我踩过的坑与排查思路

4.1 高频问题速查表

把这次调试中碰到的高频问题整理成一张速查表,以后无论自己写还是帮别人排查,都可以先过一遍这张表。

现象可能原因排查方法
密文与标准向量完全不同S盒表抄错重新核对S盒常量,或用第三方库交叉比对
密文逐字节错乱字节序不一致,解析或输出时用错大端/小端统一用big端序处理
反解密后明文不是原文解密时轮密钥没有反转检查解密函数是否用了rk[::-1]
单块加密正确,多块错误填充方式不一致,例如该用PKCS7却用了ZeroPadding明确两端填充规则
密钥扩展的结果和预期不一致L' 写成 L,或 CK 常数生成公式写错打印前几轮 rk,与可信实现对比
加密结果正常但速度极慢纯Python逐轮移位+查表开销大参考第5章的T表优化方案

其中S盒问题我碰到过不止一次,因为网上流传的S盒版本有时会混入SM3或者其他算法的常量,肉眼很难看出差异。所以我的态度很明确:S盒不要凭记忆贴,不要从二次转载的博客里复制,尽量直接从标准原文或可信实现里提取,然后用标准测试向量去验证。

4.2 一次真实的对不上密文排查经历

有一次我帮同事排查,他看到的现象是:单块加密结果和标准向量前几个字节能对上,后面开始乱。这种“前面能对上,后面对不上”的情况,首先排除密钥扩展和整体结构问题,因为如果轮密钥错了,第一轮就会错得非常离谱;反而更像填充或者分组处理的问题。

但他这个场景不是多分组,就是单分组。那问题就只剩一个方向:中间某个轮次的变换结果不对。我让他把第1轮、第2轮的 X 值打印出来,和可信实现的中间值对比,结果第1轮就对不上。接着我把第1轮的输入 t 打印出来,再手动用S盒算一遍 τ(t),发现他的S盒在第35个字节附近多了一个重复项,导致后续索引错位。

这个问题如果在早期用标准测试向量验证,10秒就能发现。但有些同事习惯“写完直接接业务数据”,从业务数据报错倒推回来,花费的时间至少是验证S盒的几十倍。所以我的建议是:算法实现完成后,第一件事永远是跑标准测试向量,没有例外。

5. 性能优化与落地使用建议

5.1 纯Python实现到底有多慢

纯Python版的SM4,速度无法和C语言实现或者OpenSSL这种原生库比。原因很简单:每一轮要拆字节、查S盒、做多次循环移位和异或,Python的解释器开销会摊到每一次操作上。如果你只是联调、测试、校验,一次加密几十个报文,完全够用。但如果你要把大量线上流量都走这个纯Python实现,就要考虑优化。

优化有两个大方向:一个是优化算法本身,走T表方案;另一个是干脆把热路径交给原生扩展,比如用Cython、numba或者直接调用成熟库。对于“对接联调”这个场景,我更推荐前者,因为代码依旧可控可读。

5.2 用T表优化替代逐轮移位

T表优化的核心思想,是把“查S盒”和“线性变换L”合并成4张预计算表。每轮计算 T(t) 时,不再先拆字节查S盒再做线性变换,而是把 t 的4个字节直接作为索引,从4张表里查出4个32位中间值,一起异或:

def build_t_tables(): T0, T1, T2, T3 = [], [], [], [] for b in range(256): x = SBOX[b] T0.append(L(x << 24) & MASK) T1.append(L(x << 16) & MASK) T2.append(L(x << 8) & MASK) T3.append(L(x) & MASK) return T0, T1, T2, T3

加密轮函数就可以写成:

def sm4_encrypt_block_t(block, rk, T0, T1, T2, T3): X = bytes_to_words(block) for i in range(32): t = X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i] tt = T0[(t >> 24) & 0xFF] ^ T1[(t >> 16) & 0xFF] ^ T2[(t >> 8) & 0xFF] ^ T3[t & 0xFF] X.append(X[i] ^ tt) Y = [X[35], X[34], X[33], X[32]] return words_to_bytes(Y)

这个做法的好处是,把每轮至少5次循环左移加异或,压缩成了4次查表加3次异或,减少了很多位运算。代价是多占一点内存,4张表各256个32位整数,总共也没几KB。实测下来速度会有可感知的提升,代码却仍然很短。

5.3 结合分组模式、填充规则和安全注意点

日常对接里,很少直接加密单个16字节分组,通常要处理任意长度的数据。这时候要确定两件事:分组模式和填充方式。

分组模式

常见的做法是CBC或者CTR。ECB虽然实现最简单,但相同的明文块会加密成相同的密文块,容易泄露数据模式,所以不推荐用在多块数据上。我一般用CBC:

模式优点注意点
ECB简单、可并行、出错影响隔离明文模式泄露风险高
CBC常见、安全性较好IV要随机且不可复用
CTR可流式、可并行计数器不能重复,需要额外认证
GCM自带认证实现复杂,纯Python场景较少用

填充方式

填充最常用的是PKCS7。规则很简单:缺几个字节就补几个值为该数量的字节。如果长度正好是16的倍数,也要额外补一整块16字节的0x10,否则解密时无法区分“原数据正好满块”和“补了一块”。

def pkcs7_pad(data: bytes, block_size=16): n = block_size - len(data) % block_size return data + bytes([n] * n) def pkcs7_unpad(data: bytes): n = data[-1] return data[:-n]

这个地方是联调最容易出分歧的点:两端填充方式不一致、一端填充一端不填充、或者明文尾部本来就是数字导致误判。所以接口文档里必须明确:UTF-8还是GBK、PKCS7还是其他自定义填充、加密后的字节是否做Base64传输、IV怎么约定。少确认一个,联调阶段就可能多花半天。

最后还有一点安全提醒,虽然和“实现”关系不大,但实际项目里特别容易忽略:不要只用加密,不保护完整性。CBC模式本身不防篡改,攻击者翻转密文比特可能会影响解密结果。如果传输的是重要业务数据,建议在密文上再叠加MAC或者直接用带认证的模式。这个经验不是教科书上的理论,是我在实际对接中看到有人把接口字段直接丢进ECB模式后,被安全评审打回来重做,才真正记住的。

最后说说我的体会

把SM4.0完整实现一遍之后,我最大的收获不是“我会写SM4了”,而是看算法文档的姿势变了。以前看到“非线性变换”“线性变换”“轮常数”这些词,觉得全是抽象概念,真正落到代码里才发现,每个部件都有明确的职责:S盒负责混淆,循环左移负责扩散,FK和CK负责把主密钥打散成互不相似的轮密钥,Feistel结构保证了解密的对称性。

如果你也在做国密对接,我建议你至少把密钥扩展和轮函数亲手写一遍。不用写到生产环境,就是本地写一个小脚本,用标准测试向量验证一遍。这个过程能帮你省掉后面联调时的大量排查时间,也让你在看到第三方库报错时,不再是两眼一抹黑的状态。

最后再分享一个实际对接的小技巧:很多系统说自己用的是“SM4”,但文档里既没写填充方式也没写编码方式。这时候你不要急着写代码,先把这几个问题一次性问清楚:明文是UTF-8还是GBK?填充是PKCS7还是ZeroPadding?加完密要不要Base64?用的什么分组模式、IV怎么约定?这些问题在需求阶段确认好,后面联调基本就是一遍过。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询