资讯动态

SM4/SMS4算法全解析:轮函数、密钥扩展与实现落地指南

发布时间:2026/10/21 8:05:16 来源:尧图企业网站定制
说实话我第一次认真啃 SMS4/SM4 的时候心里是有点发怵的。网上资料要么直接把标准甩你脸上要么上来就是一堆数学公式看起来不像密码算法倒像高考压轴题。但实际做数据加密改造、对接国密要求的时候这套算法绕不开。后来我把轮函数一行行拆开、自己手写实现跑通了一遍才真正觉得这玩意儿没那么玄。这篇就把我从名字到实现、从理论到落地踩过的坑都整理出来给准备上手 SM4 的朋友一份能直接用的参考。不管你是要自己写实现还是要用库对接搞清楚算法内部的结构排查问题时会轻松很多。1. SMS4 还是 SM4先把这个陈年旧账理清楚1.1 这两个名字是什么关系先解决最容易混淆的点SMS4 和 SM4说的是同一个算法。早年的算法描述稿里叫 SMS4后来正式成为国家标准后名字统一为 SM4 分组密码算法对应的标准文档就是大家常说的国标 GB/T 32907-2016。实际工作中你翻到老的代码、老的协议文档可能还写着 SMS4但那不影响实现直接用 SM4 的算法逻辑去对就能对上。这里我也要提醒一句国密体系里不止 SM4 一个还有 SM2非对称、SM3哈希外加 SM4 做对称加密。很多刚入门的人会把 SM4 和 SM2 搞混。记一个很朴素的区分方法SM2 是非对称用于签名和密钥交换SM4 是对称用于数据加解密。这篇只讲 SM4因为它是国密体系里出镜率最高、也最容易在落地时踩细节坑的算法。1.2 分组密码的基础框架SM4 是一个分组密码算法分组长度和密钥长度都是 128 位也就是 16 字节。听起来很整齐对吧这里面有个容易忽略的细节分组密码不是像普通文件加密那样把整个文件当一个大字符串直接加密而是先把数据切成固定大小的块再一块一块地处理。一块明确是 16 字节密钥也明确是 16 字节任何一步搞错长度后面全乱。它的整体结构是 Feistel 风格简单可以理解成“左右交叉换血”数据被拆成四个 32 位的字每一轮用其中三个字加轮密钥做出一顿操作再和剩下的那个字异或生成一个新字。这个过程重复 32 轮。32 轮听起来夸张但每一轮其实只做了很轻量的变换要靠足够多轮数来堆出安全性这和 AES 那种十来轮但每轮扩散速度更猛的结构完全不同。1.3 为什么值得花时间搞懂它如果你只是调库用可能觉得没必要看细节。我的经验是恰恰是这种“反正能跑”的心态遇到问题才抓瞎。比如同样的数据加密结果对不上标准测试向量你如果不知道字节序、不知道反序变换、不清楚填充规则根本不知道往哪排查。反过来理解了轮函数和密钥扩展问题基本一眼就能定位。而且很多行业的商用密码改造场景里系统要求必须支持国密算法你接 SM4 是迟早的事与其到时候手忙脚乱不如现在把原理一次性理顺。2. 加密轮函数拆解32 轮到底在做什么2.1 顶层视角128 位数据如何被切分和搅动先把 16 字节的明文拆成四个 32 位字记为 X0、X1、X2、X3之后每一轮都遵循同一条公式X_{i4} X_i ^ T( X_{i1} ^ X_{i2} ^ X_{i3} ^ rk_i )这里 i 从 0 到 31rk_i 是第 i 轮的轮密钥。注意看它每一轮并不是传统的左右两半互换而是把当前四个字的后三个字和后一个轮密钥异或经过一个 T 变换再和第一个字异或得到一个全新字然后推进窗口新字加入队列。这种结构严格来说属于“非平衡 Feistel”因为左右两半的处理不完全对称。我举个很土的例子你有四张卡片排成一排每轮都把第二、三、四张卡片和后一张“调味卡”轮密钥拌在一起放进一个搅拌机T 变换出来后再跟第一张卡片异或得到新卡片放到队尾然后整体往前移位。重复 32 次最后倒着输出。这样想至少公式好记多了。为什么要 32 轮因为每一轮的扩散能力相对有限单轮哪怕有 S 盒和线性变换位与位之间的混淆程度还不够只能用轮数去补。这个设计在标准调研阶段做过充分权衡不是随手定的数字。2.2 τ 变换S 盒是唯一的非线性来源T 变换里最先登场的是 τ它由 4 个并行的 S 盒构成。做法是把一个 32 位字拆成 4 个字节每个字节分别查同一个 S 盒得到 4 个新字节再拼成 32 位。S 盒本质上是一张 256 项的查表输入一个 8 位值输出一个 8 位值比如输入 0x00 对应某个固定输出输入 0x01 对应另一个固定输出。为什么非要有 S 盒因为整个算法里它几乎是唯一的非线性部件。你可以这样理解如果算法只剩异或、移位、加法这种线性运算那么整个加密过程就是一堆线性方程的叠加攻击者拿几组明文密文对就能把密钥解出来等于没加密。S 盒的存在让输入输出之间出现无法用线性关系表示的“拐弯”攻击者就不能用线性代数直接破解了。标准里直接给了一张 256 字节的固定表没有公开推倒过程但这张表是经过筛选取定的差分均匀性、非线性度、代数次数这些指标都达标。实现的时候照抄就行千万别自己“优化”改一个值。改一个字节它就不再是 SM4加解密对不上只是小事安全性有没有保证才是大事。2.3 L 变换把局部变化扩散到全块τ 做完非线性的“混乱”之后接着做线性变换 L。加密轮函数里的 L 是B ^ (B 2) ^ (B 10) ^ (B 18) ^ (B 24)这里的 表示 32 位循环左移。为什么选 2、10、18、24 这几个偏移量因为它能让一个字节改变的影响在若干轮内扩散到整个 32 位字和相邻字。打个比方S 盒负责“搞乱局部”L 负责“搅动全局”。如果只有 S 盒没有 L前面某一位的变化可能只影响后面几位攻击者很容易追踪差异。有了 L 的循环移位和异或组合一位的改动经过几轮就布满全块这叫做雪崩效应。这里还有个隐藏知识点密钥扩展用的线性变换不叫 L叫 L公式是B ^ (B 13) ^ (B 23)同样是扩散但偏移量不同。为什么密钥扩展用不同的线性变换因为轮密钥和主密钥之间必须拉开足够距离如果直接用 L扩展出来的轮密钥之间可能会呈现某种过强的线性关联削弱安全性。L 比 L 少了两个循环左移项计算上更轻量但扩散效果对密钥扩展场景已经足够。2.4 反序变换解密流程为什么能“白拿”加密做完第 31 轮后手里有 X32、X33、X34、X35 四个字。但标准规定最终密文不是按 X32 X33 X34 X35 顺序输出而是反序输出为 X35 X34 X33 X32。很多人第一次写实现时觉得这个反序是多余的跳过去不做结果发现解密永远对不上。这个反序不是凑数的它和 Feistel 结构配合得很好。正是因为最后做了一次 R 变换反序解密过程才能完全复用加密流程仅仅把轮密钥数组倒过来排。也就是说同一套加密函数传入倒序的轮密钥就是解密函数。如果没有反序这一下解密要么重新写一套逻辑要么就得忍受加密和解密流程不对称。这个细节你最晚在写代码的时候会感受到提前理解能省不少调试时间。3. 密钥扩展16 字节怎么变成 32 个轮密钥3.1 三个输入主密钥、FK、CK加密用 32 轮每轮要一个 32 位的轮密钥 rk_i所以一共要 32 个 32 位字。这 32 个字不是直接拿 16 字节主密钥切吧切吧就能出来的需要通过密钥扩展算法生成。密钥扩展有三个输入主密钥 MK长 128 位拆成 MK0、MK1、MK2、MK3 四个 32 位字。系统参数 FK也是四个 32 位字标准里写死了。它存在的意义是对主密钥做一次“初始脱敏”。如果不做这一步主密钥直接参与后续轮密钥生成前几个轮密钥可能和主密钥呈现明显的数学关系。固定参数 CK是 32 个 32 位字。它每一轮一个、只进不出目的是让 32 轮扩展过程各有各的“口味”。先说系统参数 FK标准给出的值是FK0 A3B1BAC6 FK1 56AA3350 FK2 677D9197 FK3 B27022DC这四个值固定不变实现时直接抄。再说固定参数 CK 的生成规则它比想象中更工整CK_i 的第 j 个字节 ( 4*i j ) * 7 对 256 取模其中 i 从 0 到 31j 从 0 到 3。注意这里每个字节都是按这个规则单独算最后拼成 32 位字。我第一次看到这个公式的时候觉得奇怪为什么要用这么“机械”的方式生成随机数后来想明白了标准里的常量如果是一堆看不出规律的随机数实现者很容易抄错甚至没法验证自己是否抄对。用公式生成任何人实现出来结果都一样大大降低出错的概率。3.2 扩展公式和实现密钥扩展的完整流程分两步。第一步用 FK 对主密钥做初始化K0 MK0 ^ FK0 K1 MK1 ^ FK1 K2 MK2 ^ FK2 K3 MK3 ^ FK3第二步从 i0 到 i31 逐轮生成K_{i4} K_i ^ T( K_{i1} ^ K_{i2} ^ K_{i3} ^ CK_i ) rk_i K_{i4}注意这里用的是 T即非线性变换 τ 加线性变换 L而不是加密轮函数里的 T。整个扩展的核心思想和加密轮函数很像只是扩散参数不同。下面这段 C 代码是密钥扩展的核心逻辑可以直接参考。S 盒部分我没有展开因为那 256 个字节的值必须从标准附录或开源实现里逐字核对不能凭记忆写。#include stdint.h #include string.h /* 标准 SBOX256 字节固定表见标准附录或权威开源实现 */ static const uint8_t SBOX[256]; /* 系统参数 FK */ static const uint32_t FK[4] { 0xA3B1BAC6, 0x56AA3350, 0x677D9197, 0xB27022DC }; static uint32_t rotl(uint32_t x, uint32_t n) { return (x n) | (x (32 - n)); } static uint32_t load_be32(const uint8_t p[4]) { return ((uint32_t)p[0] 24) | ((uint32_t)p[1] 16) | ((uint32_t)p[2] 8) | p[3]; } static void store_be32(uint8_t p[4], uint32_t v) { p[0] (v 24) 0xFF; p[1] (v 16) 0xFF; p[2] (v 8) 0xFF; p[3] v 0xFF; } static uint32_t tau(uint32_t a) { uint8_t b0 SBOX[(a 24) 0xFF]; uint8_t b1 SBOX[(a 16) 0xFF]; uint8_t b2 SBOX[(a 8) 0xFF]; uint8_t b3 SBOX[a 0xFF]; return ((uint32_t)b0 24) | ((uint32_t)b1 16) | ((uint32_t)b2 8) | b3; } static uint32_t L(uint32_t b) { return b ^ rotl(b, 2) ^ rotl(b, 10) ^ rotl(b, 18) ^ rotl(b, 24); } static uint32_t Lp(uint32_t b) { return b ^ rotl(b, 13) ^ rotl(b, 23); } static uint32_t T(uint32_t a) { return L(tau(a)); } static uint32_t Tp(uint32_t a) { return Lp(tau(a)); } static uint32_t gen_ck(int i) { uint32_t ck 0; for (int j 0; j 4; j) { ck (ck 8) | (((4 * i j) * 7) 0xFF); } return ck; } void sm4_key_expand(const uint8_t mk[16], uint32_t rk[32]) { uint32_t K[36]; for (int i 0; i 4; i) { K[i] load_be32(mk i * 4) ^ FK[i]; } for (int i 0; i 32; i) { K[i 4] K[i] ^ Tp(K[i 1] ^ K[i 2] ^ K[i 3] ^ gen_ck(i)); rk[i] K[i 4]; } }这里有个重要约定所有多字节数据都按大端处理也就是第一个字节是最高位。load_be32 和 store_be32 这两个函数就是在做这件事。如果直接用指针强转成 uint32_t在小端机器上读出来的数值就是反的。这也是新手最常见的翻车点我会在后面实操部分再强调。3.3 解密只需倒序为什么没有额外代价到这里你就知道为什么 SM4 的解密如此“廉价”了。解密算法和加密算法几乎一模一样区别只有轮密钥的使用顺序。加密用 rk0、rk1、rk2……rk31解密就反过来从 rk31、rk30 一路用到 rk0。为什么倒过来就能还原因为 SM4 的结构加上最后那一次反序变换让解密第一轮正好能抵消加密最后一轮的效果。你不需要为解密专门写一套新逻辑只要把轮密钥数组反转然后调用同一个处理函数就行。这个特性在硬件实现里特别省事加解密共用一套电路只需要控制轮密钥读取方向。写代码时如果用同一份轮密钥数组最简单的做法是把数组倒序复制一份再调用加密函数。注意这里别把原始 rk 数组改了因为有些场景可能同时需要加密和解密两个方向都要用。4. 动手实现一个可运行的 SM44.1 准备工作和接口设计实现 SM4 其实不需要多少代码量核心部件就几样S 盒、τ、L、L、密钥扩展、轮函数、反序输出。我建议你在动手前先把几个接口定清楚否则写到一半容易乱。块接口设计这样做比较清晰密钥开始输入 16 字节主密钥输出 32 个 32 位轮密钥。块开始输入 16 字节明文块和 32 个轮密钥输出 16 字节密文块。块解密输入 16 字节密文块和 32 个轮密钥输出 16 字节明文块内部自动倒序使用轮密钥。这里插一句SM4 一次只能处理一个 16 字节的块。如果你要加密的数据超过 16 字节必须自己选一个工作模式CBC、CTR 这些否则加密就无从谈起。这块放到下一章细说。4.2 核心代码轮函数、密钥扩展、加解密加密单个块的逻辑核心就是前面说的公式。这里我给出标准的 C 实现片段void sm4_encrypt_block(const uint32_t rk[32], const uint8_t in[16], uint8_t out[16]) { uint32_t X[36]; for (int i 0; i 4; i) { X[i] load_be32(in i * 4); } for (int i 0; i 32; i) { X[i 4] X[i] ^ T(X[i 1] ^ X[i 2] ^ X[i 3] ^ rk[i]); } /* 反序输出R 变换 */ for (int i 0; i 4; i) { store_be32(out i * 4, X[35 - i]); } } void sm4_decrypt_block(const uint32_t rk[32], const uint8_t in[16], uint8_t out[16]) { uint32_t rk_rev[32]; for (int i 0; i 32; i) { rk_rev[i] rk[31 - i]; } sm4_encrypt_block(rk_rev, in, out); }这段代码如果你自己写过一遍会发现大部分坑都集中在细节上。第一T 函数要正确区分加密轮函数和密钥扩展的 T别把 L 用到密钥扩展里也别把 L 用到加密轮函数里。第二反序输出 X[35] 开头别写成 X[32]。第三所有字读取必须通过 load_be32 之类的大端读取函数不要靠内存对齐赌运气。4.3 测试向量验证我写代码的习惯是任何密码算法实现完第一件事不是跑业务而是先拿标准测试向量做单元测试。SM4 的标准测试样本在标准附录里可以查到一个非常经典的样本是主密钥01 23 45 67 89 AB CD EF FE DC BA 98 76 54 32 10明文01 23 45 67 89 AB CD EF FE DC BA 98 76 54 32 10密文68 1E DF 34 D2 06 96 5E 86 B3 E9 4F 53 6E 42 46这个测试向量在网上流传很广很多开源实现里也直接用同一组数据。我把话说在前面如果你是在正式项目里做验收请以你手头正式标准出版物里最新附录为准我这里只是给你一个快速的自我检查基准。如果加密结果对不上排查顺序我建议这样先查 S 盒是否复制正确。S 盒 256 个字节任何一位抄错都会导致所有结果异常。再查字节序。明文、密钥、轮密钥都按大端处理了吗最后查最后的反序输出是否丢了。4.4 两个性能优化思路查表与位宽基础实现跑通之后如果你要在嵌入式或者高吞吐场景里用性能优化是绕不开的。SM4 的优化有两个很实用的方向。第一个是查表法。把 τ 和 L 合并成 4 张表每张表是“一个字节输入一个 32 位输出”。这样每一轮就不需要逐字节查 4 次 S 盒再拼接再循环移位而是直接对四个字节分别查表再异或起来。原理很简单因为 L 是线性变换异或拆开再合并是合法的T(x) T0[x0] ^ T1[x1] ^ T2[x2] ^ T3[x3]四个表可以根据 S 盒和循环左移偏移预先算好。这个优化在纯软件实现里通常能把速度提升很大一个档次代价是消耗大约 4KB 的内存在很多小平台上也能接受。第二个是利用平台的位宽和指令特性。32 位平台可以直接用 uint32_t 做循环左移比逐字节处理高效。如果有对应的国密扩展指令那就更省事了。不过优化归优化先保证功能正确再谈性能顺序别反。5. 算法落地比算法本身更讲究模式、填充与库的选择5.1 工作模式怎么选我在前面反复强调SM4 裸算法一次只处理 16 字节。实际业务里数据长度五花八门不可能刚好是 16 的倍数所以必须依赖工作模式。常见几种模式我列个表模式特点适用建议ECB每个块独立加密简单但不隐藏明文模式基本不推荐CBC每个块和前一个密文块异或后再加密需要 IV推荐做普通数据加密CTR把计数器加密后与明文异或可并行不需要填充适合流式、高性能场景GCMCTR 加认证标签能检测篡改推荐对完整性有要求的场景CCM也是带认证的模式类似 GCM 但处理方式不同适合受限环境我对模式选择的建议是能上 GCM 就尽量上 GCM。为什么因为单纯加密不解决“密文被篡改”的问题。你 CBC 加密的数据传输过程中被改了一个字节解密出来大概率是一堆乱码但你怎么知道这不是原始数据呢GCM 这类认证加密模式在加密的同时生成一个认证标签接收方先校验标签再解密篡改直接报错。对现代系统来说这是非常必要的安全能力。ECB 模式我是真的不建议用。它最大的问题是相同的明文块会产生相同的密文块。如果你加密的是一张包含大量重复区域的图片或者一份格式固定的文档密文会直接“画出”明文的轮廓信息泄露非常明显。5.2 填充规则和字节序的坑如果采用 CBC 或 ECB 这类需要填充的模式就绕不开填充规则。最常用的是 PKCS#7。规则很简单缺几个字节就补几个值为几的字节。比如数据最后剩 10 字节离 16 字节边界还差 6 字节就补 6 个 0x06。有个细节经常被忽略如果明文长度恰好是 16 的倍数PKCS#7 依然会额外的补一个完整的 16 字节块每个字节都是 0x10。为什么因为接收方要能区分“原始数据末尾正好有 1 个 0x01”和“原始末尾没有数据、填充了一个 0x01”。如果不补满一块解密方无法确定到底哪个填充字节是有效数据。字节序的坑我在前面提过。SM4 标准里多字节数据按大端存取而目前主流 CPUx86、ARM默认是小端。如果你直接把一个 char 数组用指针强制转换成 uint32_t读出来的值和标准要求相反。解密时同样会错位。正规做法就是写入读取函数按字节拼装成 uint32_t。另外CBC 和 CTR/GCM 都需要 IV。IV 不是密钥但它绝不能重复使用。同一个密钥下面如果两个消息用了同一个 IVCBC 模式下第一条消息的前 16 字节和第二条消息的前 16 字节就可能泄露异或关系CTR/GCM 模式下更严重直接可能把密钥流重放出来。IV 的正确做法是每次加密用随机数生成器产生然后随密文一起存储或传输。解密端只要拿到同一个 IV 就能正确解密。5.3 别重复造轮子库与硬件的选择我理解大家想从零实现加深理解但生产环境真的不建议用自己的手写实现。不是说手写不行而是安全算法这东西正确性之外还要考虑常数时间、侧信道、内存清零等等一堆指标。正规的密码库和硬件安全模块在这些方面经过了大量测试和审计踩坑概率小得多。那什么时候自己实现呢我建议这些场景可以学习原理做教学演示嵌入式环境没有现成库需要裁剪实现写单元测试用来验证第三方库是否正确。如果你要用现成的库找能过标准测试向量的、社区活跃的国密实现。用之前先跑一遍测试向量别信文档里吹得天花乱坠。很多库的接口参数不一样有的默认大写十六进制字符串有的默认字节数组对接时要先把这些细节录到文档里。我还有一个操作层面的建议不管你用不用库都要在项目里保留一个标准测试向量的单元测试。以后谁改了什么代码、换了一个库版本测试挂了马上就能发现。这是我在国密改造项目中收获最大的习惯。6. 常见问题与避坑实录6.1 最容易栽的坑下面这些坑我基本都亲眼见过单个拿出来都能耗掉半天调试时间现象常见原因排查方向加密后解密结果不一致反序输出漏了或轮密钥顺序没用对检查加密函数最后是否输出 X35 开始结果和标准测试向量对不上S 盒抄错找一个开源实现逐字节比对 S 盒个别字节对大部分错字节序处理不一致检查 load/store 是否全用大端同样明文加密出不同密文CBC 或 CTR 的 IV 设置不稳定确认 IV 是否每次随机并正确传递解密后末尾多出一堆 0x10填充剥离逻辑没处理完整块检查 PKCS#7 是否处理了满块补块的情况密文被改动后系统无感知用了 ECB/CBC 但没有认证机制换成 GCM 或额外计算 MAC这里我特别想强调一下“S 盒抄错”的问题。S 盒是标准里固定给出的 256 字节表没有任何推导逻辑所以从标准文档复制时很容易看串行。建议你复制后用代码写个断言统计 0 到 255 每个值是否恰好出现一次。标准 SM4 S 盒是双射表每个输出值应该正好出现一次。如果发现某个值重复、某个值缺失那就是复制错了。6.2 安全性边界SM4 到底有多强说到安全性先给结论当前公开文献里针对完整 32 轮 SM4 的有效攻击研究结论都是理论复杂性极高实际破解远远够不着。拿不到密钥就是拿不到数据这是没问题的。但算法安全不等于系统安全。我见过太多人把精力放在“这个算法到底强不强”却忽略了真正的问题密钥硬编码在代码里、IV 用固定的全零、密钥通过弱随机数生成器生成、日志里直接打印明文和密钥。这些错误跟算法本身无关你再换 AES-256 也一样会被打穿。使用 SM4 时密钥应该来自密码学安全的随机数生成器或者通过密钥协商协议动态生成不应该是字符串直接塞进来。IV 也一样每次加密都要重新生成。加密模块要做好访问控制防止调试接口泄露中间状态。只要这些基础功夫做到位SM4 在实际系统中的安全性是站得住的。6.3 与其他算法的取舍经常有人问SM4 和 AES 比怎么样我个人看法是两者在设计目标上非常接近密钥和分组长度都是 128 位安全强度在同一个量级。AES 在通用平台上有大量的硬件加速性能会很优秀SM4 则在国内商用密码生态里被广泛支持和要求国产平台上性能也不差两者的选择更多是看你的业务场景和合规要求。如果你的系统已经被要求做商用密码合规改造那对称加密这一层基本就是 SM4 的天下。如果从 AES 迁移到 SM4好消息是工作模式、填充规则、加解密接口的组织方式都差不多代码改动不会伤筋动骨大多数时候只需要替换底层核心加解密函数。还有一个容易被忽略的点SM4 经常不是单独出现。完整的一套国密方案通常是 SM2 做密钥交换或签名SM3 做哈希SM4 做对称加密。你如果只接了 SM4但密钥交换还是明文传输那等于整个链路的最后一公里还是裸奔。做国密改造的时候要从全局去看而不是孤立地把某一处算法换掉就算完事。最后分享两个个人建议。第一个手写实现 SM4 是一件非常有价值的事它能帮你在遇到加密问题时建立很清晰的直觉但生产环境务必使用权威实现并做严格测试。第二个每次改动密码相关代码后都记得跑一遍标准测试向量哪怕只是改了一个无关紧要的日志输出。密码算法是最经不起“随手改一下”的领域多一次验证就能少一次彻夜排查事故。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑