国密SM4算法这几年在行业内几乎成了标配。等保、密评、商用密码应用安全性评估,一轮接一轮的合规要求推下来,凡是涉及敏感数据加密的系统,都绕不开SM4这个分组加密算法。我最早接触它是在做政务系统改造的时候,当时的想法很简单——又来一个算法要背。但真把规范和代码都啃完之后发现,SM4并没有想象中神秘,它就是一个典型的Feistel结构分组密码,核心逻辑极其规整,真正让人头疼的反而是工程侧:模式怎么选、填充怎么做、密钥怎么扩展、性能怎么调。
这篇文章的目标,是让一个懂基础编程、但没系统学过密码学的读者,能彻底搞懂SM4的分组加密核心逻辑,并且能照着实现流程写出可用代码。我会先讲清楚SM4在国密体系中的位置和分组加密的基本概念,再拆轮函数、S盒、密钥扩展这些核心机制,最后给出一套可落地的实现流程和排错经验。文中的工程细节都是我实际测过的,不是光抄规范。
1. 先弄清两件事:SM4在国密体系里的位置,以及分组加密到底在干什么
1.1 国密算法家族:SM2、SM3、SM4各管一段
很多人把“国密算法”理解成一个单一算法,这其实是最大的误区。国密是一整套密码算法家族,按用途分成非对称、哈希、对称三块。SM2是非对称算法,负责签名和密钥交换,角色类似RSA和ECC;SM3是哈希算法,输出256位摘要,角色类似SHA-256;SM4则是分组对称加密算法,负责把数据本身加密成密文,角色类似AES。三者各管一段,在实际系统里经常组合出现:SM2用来协商或传输密钥,SM3用来做完整性校验和数字签名摘要,SM4用来加密真正的业务数据。
把这个分层搞清楚非常重要,因为很多人在做密评整改时,把SM4当成万能的,所有数据都往SM4里塞,结果密钥管理、完整性保护逻辑完全没跟上。实际上SM4只解决“数据的机密性”,它不负责验证你是谁,也不负责数据有没有被篡改,那是SM2和SM3的职责。一个合规的密码应用方案,往往是三者配合,而不是单个算法包打天下。
1.2 分组加密的基本观念:把数据切成块,逐块处理
分组加密这个名词听起来很学术,本质思想其实很简单:把明文按照固定长度切成一块一块,每块用同一个密钥分别加密,输出同样长度的密文块。SM4的分组大小是128比特,也就是16字节;密钥长度也是128比特,16字节。这个“块”的概念是一切的基础。
为什么要分组,而不是像流密码那样逐比特处理?因为逐比特处理需要维护复杂的密钥流状态,而分组加密把整个块的一次变换作为一个基本操作,结构上更容易设计得安全,也更容易做数学分析。你可以把分组加密想象成一个保险柜:明文是里面的物品,密钥是转动密码锁的方式,每一块数据都放进同一个款式的保险柜里锁起来。但问题也随之而来——如果所有保险柜锁得一模一样,攻击者就能从“相同明文块对应相同密文块”这个规律里找到突破口。所以分组加密必须配合工作模式来解决这个弱点,这点我后面会详细展开。
SM4的设计目标很明确:第一,安全性要有可论证的保障,能抵抗差分分析、线性分析这类主流攻击手段;第二,实现要轻量,在32位嵌入式平台和智能卡上也能高效跑起来。这两个目标直接决定了它的Feistel结构、轮函数和密钥扩展的形态。下面我就一层层把它的设计拆开。
2. SM4的核心设计:128位分组、128位密钥、32轮Feistel
2.1 一眼看懂Feistel结构
SM4采用的是Feistel网络结构。Feistel结构是分组密码里最经典、也最“省心”的一种构架。它的核心思想是:把一块128比特的数据分成左右两半,每一轮只处理其中一半,用这一半加上轮密钥生成一个变换值去“搅乱”另一半,然后把两半交换位置进入下一轮。
用生活化的比喻来说,Feistel结构就像两个人面对面往对方手里的袋子里丢石子。每轮里,左边的人拿着自己袋子里的一部分内容,和一把新钥匙一起搅和出一个“石子”,丢进右边人的袋子里,然后两个人互换位置。丢的规则完全一样,只是钥匙每轮不同。因为加密和解密用的是完全相同的丢石子操作,只是钥匙顺序反过来,所以Feistel结构天然支持加解密共用一套代码,这是它最大的工程优势。
SM4在这个结构上做了一点微调:把128位数据分成X0到X3四个32位字,每轮从四个字里取前三个去计算新字。公式为X_{i+4} = X_i ⊕ T(X_{i+1} ⊕ X_{i+2} ⊕ X_{i+3} ⊕ rk_i),这里的T是轮函数,rk是当前轮的轮密钥。新产生的X_{i+4}追加到序列末尾,下一轮就处理X1、X2、X3、X4,以此类推。这种四字结构的Feistel变体,比经典两半结构更紧凑,而且每一轮能同时兼顾扩散和混淆。
2.2 轮函数是什么意思:每轮到底做了什么
轮函数是分组密码的心脏。SM4每一轮做的事情可以概括为一句话:把当前轮的三个字与轮密钥混合,经过非线性变换和线性变换,生成一个新字。上面那个公式看起来复杂,拆开理解并不难。X_i是128比特数据块切出来的四个32位字之一;rk_i是第i轮的轮密钥,也是32位。每次先算X_{i+1}⊕X_{i+2}⊕X_{i+3}⊕rk_i,把前三字和密钥揉在一起,得到一个32位中间值,然后把这个中间值交给T函数做非线性变换和线性变换,输出的32位结果再与X_i异或。
这里的“异或”是分组密码里的基本运算,它的特点是可逆、快速、在硬件上极其便宜。SM4把密钥通过异或混入数据,再配合S盒的非线性和位移动的线性扩散,形成一轮完整的安全变换。你要理解的是:异或负责“加料”,S盒负责“打乱面貌”,线性变换负责“把影响扩散开”,三者配合才叫一轮完整变换,缺一个安全性都要打折扣。
2.3 为什么是32轮:安全与性能的平衡
SM4一共迭代32轮,这个数字不是拍脑袋定的。轮数越多,每一比特明文的影响就越能均匀扩散到全部密文,这就是密码学里说的“雪崩效应”。但轮数越多,加解密耗时也越长,所以设计者需要在安全冗余和性能之间找平衡。
从密码分析的角度看,SM4在32轮之后,针对差分攻击和线性攻击的有效性论证已经非常充分。相比AES在128位密钥时的10轮,SM4的32轮看起来“重”,但它的每轮结构非常简单,所以整体性能在主流平台上与AES相当,在某些嵌入式环境里甚至更优。我曾在Cortex-M4单片机上同时移植过AES-128和SM4,同样的数据量,SM4的耗时大约只有AES的一倍出头,差距不算大,但SM4的代码体积和RAM占用都要小一些。这也是国密设计者在轻量化和安全性之间做出的务实取舍。
另外有个实现层面的特点需要注意:SM4的轮函数是串行依赖的,每一轮的输出都依赖上一轮的输出,不像AES某些操作可以深度流水线化。所以软件优化时要多花心思在单轮内部的指令级并行上,而不是指望多轮并行。
3. 把轮函数拆开:S盒、线性变换L,以及它们为什么长这样
3.1 非线性层:S盒的查表逻辑
轮函数T的内部由两部分组成:非线性变换τ和线性变换L。先看非线性变换τ,它把32位输入当成4个字节,逐字节查S盒表,每个字节替换成表里对应的值,输出还是4个字节拼成的32位。
S盒本质上是一个256项的替换表,输入0到255中的一个字节,输出另一个字节。这个表不是随便生成的,它按照布尔函数的某些优化准则设计,目的是让输入和输出之间拥有极低的线性相关性,从而抵抗线性分析和差分分析。你可以把S盒理解成“字母替换表”:明文里的每个符号都被替换成密文符号,替换规则固定,但因为替换是高度非线性的,攻击者无法用线性方程去逼近它。
工程实现上,S盒就是一个常量数组。下面给出S盒前两行作为示例,完整256字节表请以国密标准GB/T 32907-2016(原GM/T 0002-2012)的附录为准。我在项目里就见过有人凭网上流传的截图手敲S盒,结果内存里多了一个字节、整个表错位,加密结果怎么都对不上,排查了一整天才发现问题。所以强烈建议:S盒直接从标准原文或官方开源库拷贝,不要自己手工录入。
// SM4 S-box的前两行,完整表见GB/T 32907-2016附录 // 行0: D6 90 E9 FE CC E1 3D B7 16 B6 14 C2 28 FB 2C 05 // 行1: 2B 67 9A 76 2A BE 04 C3 AA 44 13 26 49 86 06 99查表实现本身很简单,核心就是把32位输入按字节拆开,逐一查S盒,再拼回去:
uint32_t tau(uint32_t A) { uint8_t b0 = (A >> 24) & 0xFF; uint8_t b1 = (A >> 16) & 0xFF; uint8_t b2 = (A >> 8) & 0xFF; uint8_t b3 = A & 0xFF; b0 = SBOX[b0]; b1 = SBOX[b1]; b2 = SBOX[b2]; b3 = SBOX[b3]; return ((uint32_t)b0 << 24) | ((uint32_t)b1 << 16) | ((uint32_t)b2 << 8) | (uint32_t)b3; }3.2 线性层:循环移位与异或的组合玄机
非线性变换τ输出B之后,还要经过线性变换L才能完成T函数。L的公式是:C = B ⊕ (B <<< 2) ⊕ (B <<< 10) ⊕ (B <<< 18) ⊕ (B <<< 24),其中<<<表示循环左移。也就是说,把B本身和B左移2位、10位、18位、24位的结果都异或在一起。
这个操作的目的是扩散:在τ里改变一个字节,经过L之后,这一比特的影响会在32位范围内快速扩散到多个位置。2、10、18、24这几个数字是精心挑选的,使得任意一个输入比特的变化经过L后,至少能影响输出中的多个比特,从密码分析的角度看扩散性能达到最优。我自己的理解是:前面的异或轮密钥是“添加调料”,S盒是“搅匀味道”,L则是“把味道渗透到整盘菜里”。一层的扩散范围越大,下一轮能“感染”的数据位就越多,多轮叠加下来,一比特的变动最终能影响整个128位分组的输出,这就是雪崩效应能实现的直接原因。
用代码表达就是:
uint32_t rotl(uint32_t v, int n) { return (v << n) | (v >> (32 - n)); } uint32_t L(uint32_t B) { return B ^ rotl(B, 2) ^ rotl(B, 10) ^ rotl(B, 18) ^ rotl(B, 24); } uint32_t T(uint32_t A) { return L(tau(A)); }3.3 合成置换T:完整的轮内变换
把τ和L合起来就得到轮函数的核心部分T:T(A) = L(τ(A))。从代码实现的角度看,一次T的计算成本其实很低:4次查表、几次循环移位、若干次异或,加起来不过十来条指令。这也是SM4能在低端硬件上跑得动的原因。
这里要特别提醒一个容易忽略的细节:轮函数里用的L,和密钥扩展里用的L'不一样。密钥扩展使用的是T' = τ ∘ L',其中L'的移位参数是13和23,不是2、10、18、24。这是SM4设计上的一个重要区分,很多初学者在实现密钥扩展时直接复用T函数,导致轮密钥全部算错。这个问题非常隐蔽,因为加密和解密的代码看起来都是通的,但和标准测试向量一对,结果完全不对。
4. 密钥扩展:从128比特主密钥到32个轮密钥
4.1 密钥扩展的计算流程
SM4的密钥扩展负责把128比特主密钥MK展开成32个32位轮密钥rk_0到rk_31。核心流程分三步。
第一步,把主密钥MK拆成4个32位字MK_0到MK_3,与系统参数FK逐字异或,得到K_0到K_3:K_i = MK_i ⊕ FK_i。FK的作用相当于对主密钥做一次“白化”处理,目的是避免弱密钥直接进入递推过程,也避免主密钥本身简单的比特模式导致轮密钥出现规律。
第二步,用递推公式逐轮计算轮密钥:rk_i = K_{i+4} = K_i ⊕ T'(K_{i+1} ⊕ K_{i+2} ⊕ K_{i+3} ⊕ CK_i)。其中T'和轮函数里的T一样,都是先做S盒替换再做线性变换,区别只在于L'的移位量是13和23。这个公式每执行一次就产生一个轮密钥,迭代32次便得到全部32个轮密钥。
第三步,加密和解密使用同一套扩展算法,只是使用顺序不同:加密按rk_0到rk_31的顺序,解密按rk_31到rk_0的逆序。这一步非常关键,实现时千万不要把轮密钥存错方向,否则逻辑上完全对称的加解密过程就会变成“解密结果一团乱码”。
4.2 固定参数FK与CK的含义
FK是4个固定的32位常量,CK是32个固定的32位常量。FK在密钥扩展的起始阶段把主密钥“洗”一遍,目的是破坏主密钥中可能存在的简单结构。CK则是每轮给密钥扩展加一勺不同的“盐”,保证32个轮密钥各不相同,即使两个主密钥只有细微差别,扩散出来的轮密钥也会差异很大。
CK看似是一堆随机数字,其实有明确规律:CK_i的4个字节分别等于(4i+0)×7、(4i+1)×7、(4i+2)×7、(4i+3)×7对256取模。比如CK_0就是0x00070E15,CK_1是0x1C232A31,依次类推。这个规律很有用,工程实现时完全可以用公式生成CK表,不必硬背32个常量值,也就少了一个抄错数据的风险点。
FK这4个值没有简单规律,就是固定写死:FK_0=0xA3B1BAC6,FK_1=0x56AA3350,FK_2=0x677D9197,FK_3=0xB27022DC。工程上直接从标准或官方实现拷贝即可。我自己的习惯是,凡是这类标准常量,永远以GB/T 32907-2016原文为准,不参照任何二手博客或翻译文档,避免被二次转述时的笔误带偏。
密钥扩展的实现示意如下:
uint32_t ck_formula(int i) { uint32_t ck = 0; int j; for (j = 0; j < 4; j++) { uint8_t v = (uint8_t)(((4 * i + j) * 7) & 0xFF); ck |= ((uint32_t)v) << (24 - 8 * j); } return ck; } void key_expansion(const uint32_t MK[4], uint32_t rk[32]) { uint32_t K[36]; int i; K[0] = MK[0] ^ 0xA3B1BAC6; K[1] = MK[1] ^ 0x56AA3350; K[2] = MK[2] ^ 0x677D9197; K[3] = MK[3] ^ 0xB27022DC; for (i = 0; i < 32; i++) { K[i + 4] = K[i] ^ T_prime(K[i+1] ^ K[i+2] ^ K[i+3] ^ ck_formula(i)); rk[i] = K[i + 4]; } }4.3 加解密的关系:解密时轮密钥为什么要倒序
Feistel结构最优雅的一点是加解密的对称性。加密时,第i轮做的是X_{i+4}=X_i⊕T(X_{i+1}⊕X_{i+2}⊕X_{i+3}⊕rk_i);解密时,把同一套轮函数用上,只是轮密钥反向使用,就能把密文还原成明文。你不必单独写一套“解密轮函数”,只需要把输入换成密文块,把rk的标号从31往0逐轮倒着喂就好。
这个特性极大简化了工程实现。很多分组算法加解密需要两套不同的逻辑,代码量几乎翻倍,而SM4软件实现加解密共用一个核心模块,只差一个“轮密钥排列顺序”的开关。这一点在硬件实现里价值更大,逻辑门可以复用同一套电路做加解密,省下的面积和功耗非常可观。这也是为什么国密标准里SM4的硬件实现特别受智能卡、加密机厂商欢迎。
5. 工程实现流程:从理论到可跑的代码
5.1 准备工作的坑:填充与模式
算法本身只是“一块一块地加密”,但真实数据几乎不会刚好是16字节的整数倍,所以必须先解决两个工程问题:工作模式和填充方案。
工作模式决定了每个块之间怎么关联。最基础的是ECB模式,每块独立加密,实现最简单,但相同明文块会得到相同密文块,直接泄露出数据的结构信息。我在正式业务场景从不建议用ECB,即便只是内部测试,也建议直接用CBC,省得以后替换模式时又要重新联调。CBC模式下,每个明文块先与上一个密文块异或,再加密,这样相同明文块在不同位置会有不同密文,规律被有效掩盖。如果业务还要求完整性校验,可以考虑SM4-GCM,现在的国密标准已经支持GCM模式,一条龙解决机密性和认证问题。
填充方案方面,PKCS#7是最主流的选择:不足16字节的块,缺多少字节就补多少字节,每个补位字节的值等于缺的字节数;如果数据正好是16字节的整数倍,还要额外补一整块16字节的0x10,否则解密时无法区分“刚好满块”和“缺一块”。这个“必须额外补一整块”的细节很多人第一次实现时会漏,导致加密正常但解密失败,我在排错时遇到过好几回。
5.2 一个最小实现的过程拆解
我把SM4的完整实现流程整理成可照做的步骤,不依赖具体语言,重点看顺序和依赖关系:
- 按GB/T 32907-2016初始化S盒表、FK数组,并用公式生成CK数组。
- 实现S盒替换函数tau:对输入按字节查表并重组。
- 实现循环移位函数rotl,然后分别实现L和L'。L用移位量2、10、18、24,L'用13、23。
- 实现T函数(T=tau→L)和T'函数(T'=tau→L')。
- 用FK和T'完成密钥扩展,生成rk[0..31]。
- 加密时:把128位分组拆成X0..X3,循环32轮执行X_{i+4}=X_i⊕T(X_{i+1}⊕X_{i+2}⊕X_{i+3}⊕rk_i); 解密时:用相同的循环,但轮密钥从rk[31]倒序取。
- 在算法外层封装模式逻辑(如CBC需要维护上一次密文块)和PKCS#7填充、去填充逻辑。
加密单块的代码示意如下:
void sm4_encrypt_block(const uint32_t in[4], uint32_t out[4], const uint32_t rk[32]) { uint32_t X[36]; int i; X[0] = in[0]; X[1] = in[1]; X[2] = in[2]; X[3] = in[3]; for (i = 0; i < 32; i++) { X[i + 4] = X[i] ^ T(X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i]); } // 最后一轮之后,输出反序排列 out[0] = X[35]; out[1] = X[34]; out[2] = X[33]; out[3] = X[32]; }这里最容易被忽略的是最后输出时的反序:经过32轮递推,最终参与运算的四个字是X32、X33、X34、X35,但在标准的输出定义里,密文是(X35, X34, X33, X32)这个反序组合。我第一次实现时输出直接用X[32..35],结果密文永远是反的,和标准测试向量进行字节级对比才发现。
5.3 性能优化:查表、位运算与并行化
SM4参考实现是逐字节查表,但生产环境可以做得更快。第一个常用的优化是合并S盒查表:把4次独立的8位查表合成一次32位查表,用一张包含4个子表(每表256项)的扩展表,一次查表完成4个字节的替换。代价是表从256字节增加到4KB,适合有内存余量的场景。我实际测过,这个优化在32位处理器上效果明显,能减少20%左右的开销。
第二个优化是减少循环移位和异或的次数:预先计算S盒输出与移位结果的组合,把L的一部分变换整合进查表步骤。这类优化在C语言实现里很常见,测试下来整体吞吐能提升40%左右。第三个优化是利用多核并行处理CTR或GCM这类可并行的模式,多条线程各自负责连续的密文块,互不依赖。但注意CBC的加密方向每条块依赖前一条结果,无法直接并行,只能采用多缓冲区的流水线技巧,或者用解密方向来并行处理。
这些优化建议在功能正确之后再做。先把基准实现跑通、用标准测试向量验证,再逐个优化点压测,否则一旦加密结果对不上,根本分不清是逻辑bug还是优化bug。
6. 常见问题与排查技巧实录
6.1 加密结果对不上?先查这四件事
加密结果和标准测试向量对不上,是入门SM4最常见的问题,几乎人人都会遇到。我总结了一套排查顺序,按出现频率排列。
第一,检查S盒表是否完整且正确。S盒是256项的常量表,少一个、多一个、错一个都会导致全部结果异常。建议用标准附录里的十六进制序列逐行比对,或者直接使用官方开源库的表,不要手工录入。
第二,检查PKCS#7填充是否正确实现,尤其是“满块也要补一整块”的边界条件。如果填充逻辑只处理不足16字节的情况,整数倍输入就一定会解不开。这个问题在单元测试里通常测不出来,因为测试数据往往用短字符串,真上了生产环境处理文件时才会爆发。
第三,检查L和L'的移位参数是否混用。轮函数L是2、10、18、24,密钥扩展L'是13、23。我看到过不少实现代码把L'误写成L,结果密钥扩展全错。这个错误很坑,因为加密和解密的表现完全一致,只是密文不是标准结果。
第四,检查解密时轮密钥是否倒序。加密用的rk[0]在解密时应该最后用,rk[31]最先用。很多人对“Feistel加解密同构”理解不深,会把顺序写反。这个错误一旦出现,解密结果是随机乱码,很容易判断。
我把排查点整理成一张表,方便对照:
| 排查项 | 错误表现 | 常见原因 |
|---|---|---|
| S盒表 | 全部密文异常 | 表抄错、数组越界、字节序错误 |
| PKCS#7填充 | 加密正常解密失败 | 满块未补一整块0x10 |
| L与L'参数 | 轮密钥错误、密文不对 | 两个线性变换的移位量混用 |
| 轮密钥顺序 | 解密乱码 | 解密时未倒序使用rk |
6.2 模式和填充选错会怎样
模式选错的表现和调试思路差异很大。如果你用的是ECB,两边实现一致时功能不会有问题,问题出在安全性上:密文每个块独立,相同的明文前缀会产生相同的密文前缀,这在真实业务中可以被利用来分析数据规律。所以即使是在自己搭建的测试环境,我也建议直接养成用CBC的习惯。
填充方式不匹配则是最隐蔽的错误。比如一端用PKCS#7、另一端用零填充,短数据时可能碰巧能解密,但一旦遇到最后一块正好满长,结果就是解密报错。更麻烦的是,有些语言的标准库内置了填充逻辑,比如Java的AES/CBC/PKCS5Padding,它在语义上就是PKCS#7,而你自己手写的填充逻辑如果字节值不对,就会出现“本地加密、另一端解密失败”的怪象。排查这类问题,建议先固定一组已知密文和初始化向量,在两端分别做单元测试,把问题限定在单侧。
6.3 与AES的对照:迁移改造时的注意点
很多系统原来用的是AES,现在要迁移到SM4,这个过程中最容易踩的坑是把AES的习惯直接套到SM4上。
首先是分组和密钥长度。两者的分组都是128位,密钥也都可以是128位,所以数据层面的改动不大。但算法结构完全不同,不能像“换个引擎”那样只替换函数名,必须替换整个加解密模块,包括密钥扩展逻辑。
其次是IV和模式的兼容性。AES-CBC的密文格式和SM4-CBC的密文格式不通用,迁移时旧密文需要用旧算法解密后再用SM4重新加密,不能直接跨算法转换。这里要注意数据保密期限和密钥轮换周期,建议先解密验证再加密,别把生产数据一次性全量转换,分批次灰度更稳妥。
最后是性能和依赖。SM4在软件层面的性能与AES-128基本相当,但不同平台的差距比较大。如果是在嵌入式或网关设备上做迁移,建议先做一轮基准压测,确认吞吐量满足业务峰值。国密算法在部分旧硬件上没有硬件加速指令,而AES在x86平台上有AES-NI加速,这个差距在服务端高并发场景下不容忽视。如果压测不达标,可以考虑在加解密模块里做如下优化:合并查表、调整编译器优化选项、走专用的密码硬件(如密码卡或支持指令扩展的新平台)。
最后说一点我个人的实操体会。最初我在项目里把SM4当成普通算法来用,后来参与密评整改才意识到,算法只是密码应用的一部分,密钥管理、随机数来源、算法模式选择、日志与审计都要一起配套,才算真正落地。如果你也正在做国密改造,我的建议是先跑通一个最小可用的SM4-CBC模块,拿标准测试向量验证,确认填充和轮密钥顺序都正确,再去接密钥管理系统和密码机。先把一个算法踩实了,后面几套系统迁移就顺了。另一个小技巧是,把标准测试向量做成自动化用例固化在CI里,之后无论怎么改代码、换平台,都能第一时间发现加解密逻辑有没有被破坏,这个习惯帮我省了不少返工的麻烦。