说实话,我第一次认真啃 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_{i+4} = X_i ^ T( X_{i+1} ^ X_{i+2} ^ X_{i+3} ^ rk_i )这里 i 从 0 到 31,rk_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 到 31,j 从 0 到 3。注意这里每个字节都是按这个规则单独算,最后拼成 32 位字。我第一次看到这个公式的时候觉得奇怪,为什么要用这么“机械”的方式生成随机数?后来想明白了:标准里的常量如果是一堆看不出规律的随机数,实现者很容易抄错,甚至没法验证自己是否抄对。用公式生成,任何人实现出来结果都一样,大大降低出错的概率。
3.2 扩展公式和实现
密钥扩展的完整流程分两步。
第一步,用 FK 对主密钥做初始化:
K0 = MK0 ^ FK0 K1 = MK1 ^ FK1 K2 = MK2 ^ FK2 K3 = MK3 ^ FK3第二步,从 i=0 到 i=31 逐轮生成:
K_{i+4} = K_i ^ T'( K_{i+1} ^ K_{i+2} ^ K_{i+3} ^ CK_i ) rk_i = K_{i+4}注意这里用的是 T',即非线性变换 τ 加线性变换 L',而不是加密轮函数里的 T。整个扩展的核心思想和加密轮函数很像,只是扩散参数不同。
下面这段 C 代码是密钥扩展的核心逻辑,可以直接参考。S 盒部分我没有展开,因为那 256 个字节的值必须从标准附录或开源实现里逐字核对,不能凭记忆写。
#include <stdint.h> #include <string.h> /* 标准 SBOX:256 字节固定表,见标准附录或权威开源实现 */ 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. 动手实现一个可运行的 SM4
4.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 | 把计数器加密后与明文异或,可并行,不需要填充 | 适合流式、高性能场景 |
| GCM | CTR 加认证标签,能检测篡改 | 推荐对完整性有要求的场景 |
| 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 标准里,多字节数据按大端存取,而目前主流 CPU(x86、ARM)默认是小端。如果你直接把一个 char 数组用指针强制转换成 uint32_t,读出来的值和标准要求相反。解密时同样会错位。正规做法就是写入读取函数,按字节拼装成 uint32_t。
另外,CBC 和 CTR/GCM 都需要 IV。IV 不是密钥,但它绝不能重复使用。同一个密钥下面如果两个消息用了同一个 IV,CBC 模式下第一条消息的前 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 是一件非常有价值的事,它能帮你在遇到加密问题时建立很清晰的直觉,但生产环境务必使用权威实现并做严格测试。第二个,每次改动密码相关代码后,都记得跑一遍标准测试向量,哪怕只是改了一个无关紧要的日志输出。密码算法是最经不起“随手改一下”的领域,多一次验证,就能少一次彻夜排查事故。