资讯动态

纯JS实现SM4加密:从算法原理到跨端联调完整指南

发布时间:2026/9/26 17:12:50 来源:尧图企业网站定制
1. 为什么非要纯JS地实现SM4加密而不是直接装个库如果你最近接过政务云对接、工业网关配置页或者任何带国密字样的项目应该对这句话很熟接口文档里轻描淡写地写着敏感字段采用SM4算法加密。后端的Java、Python都好说库一拉就能调。麻烦的是前端浏览器里没有OpenSSL也不能指望用户环境里跑着一个Node服务替你算。于是一个不依赖任何运行时能力的SM4纯JS实现就成了绕不开的硬需求。我最初的想法也是先去npm上翻现成的包。翻了一圈之后发现能用的确实有但它们要么体积太大要么API和项目里的风格对不上要么干脆依赖了Node的内置模块到了浏览器环境还得再包一层polyfill。最后我决定自己动手把SM4在纯JS环境里完整实现一遍。这篇文章就是我从读标准文档到跑通前后端联调的完整记录包含算法拆解、JS位运算陷阱、ECB/CBC模式封装以及跨端联调时最容易被坑的几个细节。不管你是写管理后台脚本、做嵌入式设备的Web配置页还是要给小程序端补一套国密工具这篇都应该能帮你省下至少一个下午。1.1 三个最常见的落地场景先说场景不然很多人会觉得SM4离自己挺远。第一个是政务、金融类系统的国密改造。这类项目里面向前端的接口常常要求把手机号、身份证号、银行卡号之类的敏感字段用SM4加密后再上送服务端只认国密算法的结果。这个时候前端就必须有一套可用的SM4实现。第二个场景是工业设备或者边缘网关的本地Web配置页。设备内置了一个轻量HTTP服务浏览器直接访问设备IP就能打开配置界面。由于不经过后端中转前端和设备的通信加密只能靠页面里的JS自己完成。这种环境往往没有外网、没有包管理器一个单文件的纯JS加密实现反而成了最稳妥的解法。第三个场景是跨端复用。公司里同一个业务可能同时要跑在PC浏览器、微信小程序、App内嵌WebView甚至Node脚本里。如果每种环境各写一套加密逻辑分散很难维护写成一段不依赖任何平台API的纯JS代码反而能在所有端上一份代码跑到底。我自己实际遇到的就是第三个场景当时函数签名从Node换到浏览器只改了输入输出的编码格式加密核心一行没动。1.2 现成库为什么总觉得差点意思调研的时候我重点看了sm-crypto、gm-crypto、gmssl这几个包。sm-crypto确实是最常用的SM2、SM3、SM4都覆盖文档也全但它把SM2的大数运算、SM3的哈希逻辑全打进一个包里按需引入时依然会有不少冗余代码。gm-crypto的API设计更贴近Node风格但部分底层依赖了Node的crypto和Buffer浏览器环境下要么自己打补丁要么就得切打包器的alias折腾一圈下来比自己写一个SM4还累。这里顺便给刚接触国密的朋友做个区分SM4是对称加密算法分组128位、密钥128位加密和解密用同一个密钥它跟AES是同一个层面的东西只是算法不同、标准来源不同。MD5则根本不是加密算法它是哈希摘要不可逆。很多人把MD5叫MD5加密严格来说是错误的叫法它跟SM4、AES完全是两类东西。下面这个表可以帮你快速建立概念算法类型分组长度密钥长度是否可逆典型用途SM4分组密码128位128位可逆国密合规的数据加密AES分组密码128位128/192/256位可逆通用数据加密MD5哈希摘要512位无不可逆完整性校验、口令摘要回到正题。自研的最大好处是可控要什么模式就留什么模式不要padding就去掉padding输出的Hex大小写、是否带0x前缀都能按对接方的要求来调。在联调场景里这个灵活度有时候比开箱即用更值钱因为对方的加密格式未必和库的默认值一致。1.3 自己啃原理的隐性收益还有一个隐性收益很多人没意识到联调出问题的时候你能自己拿笔算。真实项目里前后端加密结果对不上是家常便饭。如果你手头只有黑盒库你只能对着两个十六进制字符串发呆然后去猜是IV传错了还是填充对不上。但如果你亲手写过SM4的轮函数和密钥扩展你会自然而然地按分组方式、密钥扩展、填充规则、模式处理这个顺序去拆问题定位速度快得多。这也是我坚持把原理部分写在前面、代码放在后面的原因。2. SM4的算法骨架128位分组、32轮迭代与那个T置换2.1 先建立整体印象SM4是一个分组密码算法数据分组长度128位密钥长度也是128位。你可以把它想象成一台一次处理16字节的机器把16字节的明文喂进去经过32轮重复相同的变换输出16字节的密文。AES也是类似的结构但AES用的是SPN结构Substitution-Permutation NetworkSM4用的是类似Feistel的结构每一轮迭代时只更新128位数据中的一部分。更具体地说SM4把128位数据切成4个32位的字记为X0、X1、X2、X3。每一轮根据当前4个字算出一个新字X_{i4}公式是X_{i4} X_i ⊕ T(X_{i1} ⊕ X_{i2} ⊕ X_{i3} ⊕ rk_i)这里rk_i是第i轮的轮密钥一共32轮所以需要32个轮密钥。整体上每一轮就是先把后三个字和轮密钥异或然后过一遍T置换再把结果和第一个字异或得到一个全新字。32轮跑完之后把(X35, X34, X33, X32)反序输出这就是密文。反序输出这个细节很关键我第一次写实现时忘了最后反序结果前16字节里只有前4字节是错的排查非常费劲。这个结构的好处是加解密几乎对称。解密的时候不需要单独写一套逆算法只需把32个轮密钥逆序使用用完全相同的轮函数跑一遍就能还原明文。这是Feistel类结构的通用特性也是SM4实现起来比AES省事的原因之一。2.2 T置换非线性层S盒与线性层LT置换是SM4轮函数的心脏它由两部分组成非线性变换τ和线性变换L。τ本身又由4个8进8出的S盒并联而成。32位的输入被拆成4个字节每个字节分别查同一个S盒得到4个新字节再拼回一个32位的字。这个S盒就是GB/T 32907-2016标准附录里那张公开的256字节查表。生活化地理解S盒就是一本换字典。查表的意思是把一个字节的值当作字典的索引取出来的值当作替换结果。比如0xD6查出来是0x900x90查出来是0xE9前几个值我记得比较牢后面的请以标准表为准。整个过程没有任何数学性质可以推导它存在的意义是打乱输入与输出之间的线性关系让加密结果对密钥和明文的变化足够敏感。τ的输出还要再过一个线性变换L公式是B B ⊕ (B 2) ⊕ (B 10) ⊕ (B 18) ⊕ (B 24)这里的表示循环左移不是普通左移。L没有引入新的非线性它的作用是扩散——把S盒输出的每个比特的影响快速扩展到整个32位字。你可以把L理解为信息搅拌机只查S盒而不搅拌的话某个位置的比特变化只影响局部经过L的多次循环移位和异或一个比特的变化可以迅速影响超过一半的输出位。这就是分组密码中经典的混淆与扩散设计理念。2.3 密钥扩展16字节主密钥如何变成32个轮密钥SM4的主密钥就是16个字节但32轮迭代需要32个32位的轮密钥所以第一步要做密钥扩展。扩展过程也很规整先把主密钥拆成4个32位字MK0、MK1、MK2、MK3分别和4个固定的系统参数FK异或得到K0、K1、K2、K3。这一步相当于给原始密钥做一次白化防止弱密钥直接进入轮函数。接下来循环生成K4到K35。第i个轮密钥rk_i等于K_{i4}计算方式是K_{i4} K_i ⊕ T(K_{i1} ⊕ K_{i2} ⊕ K_{i3} ⊕ CK_i)注意这里的T和轮函数里的T几乎一样S盒完全相同只是最后的线性变换换成了另一个移位组合B B ⊕ (B 13) ⊕ (B 23)CK是32个固定的32位常量在国标里由系统参数通过一个固定生成算法算出实际使用时直接抄成常量数组即可。FK的4个值是公开的0xA3B1BAC6、0x56AA3350、0x677D9197、0xB27022DC。我在第一次看密钥扩展时犯过迷糊以为CK和FK是保密的实际上这套国标算法所有参数都是公开的SM4的安全性完全不依赖参数保密。理解这一点后你从标准文档里抄S盒和CK时就不会心虚了。3. 纯JS实现的三道坎位运算、字节序和填充3.1 JS位运算的32位有符号陷阱把SM4算法搞懂之后我本来以为写JS实现也就是照抄公式的事结果第一行代码就踩了坑。问题出在JavaScript的位运算上JS里所有的位运算移位、异或、按位与/或都会先把操作数转成32位有符号整数再执行。这意味着0xFFFFFFFF这个数一旦参与了位运算在运算过程中会被当成-1处理。最典型的例子是循环左移。SM4里大量使用循环左移这种位操作比如rotl(x, 2)代表把x的比特整体左移2位左边溢出的两位补到右边。C/C里直接写(x n) | (x (32 - n))就行但JS里如果照抄当x的符号位是1时右移用的是带符号右移会把高位的1一直填充过来结果完全错误。正确做法是使用无符号右移。我实际用的循环左移函数是function rotl(x, n) { if (n 0) return x 0; return ((x n) | (x (32 - n))) 0; }最后的 0是把结果强制转回无符号32位整数。这一步不能省否则后面参与异或运算时值可能因为被重新解释成负数而出现莫名其妙的错误。另一个细节是JS对移位量做了取模处理位移32位等于位移0位所以x (32 - 0)在n0时不会出错但我还是习惯用if判断写着更安心也让读代码的人不用猜。3.2 大端字节序一个字的排列顺序不能含糊第二道坎是字节序。SM4标准规定128位数据按大端字节序切分成4个32位字输入的第0~3个字节组成X0第4~7个字节组成X1以此类推。拿十六进制明文0123456789abcdeffedcba9876543210来说X0就是0x01234567X1是0x89abcdefX2是0xfedcba98X3是0x76543210。这个顺序一旦搞反所有结果都会错。我记得有个同事在实现时用了小端序拼接前8个字节的密文看起来还能对上后面全乱排查了几个小时才发现是DataView读字节序的问题。JS里如果你用DataView的getUint32方法默认就是大端序正好和SM4的要求一致但如果用Uint8Array手动拼就得自己注意高位字节在前function bytesToWords(bytes) { const words new Uint32Array(Math.ceil(bytes.length / 4)); for (let i 0; i words.length; i) { words[i] ( (bytes[i * 4] 24) | (bytes[i * 4 1] 16) | (bytes[i * 4 2] 8) | bytes[i * 4 3] ) 0; } return words; }反过来把4个32位字还原成16字节输出时同样要大端拆开每字节取高8位。只要这两个方向都统一走大端就不会出问题。3.3 PKCS#7填充与CBC模式下IV的配合SM4是分组密码一次加密固定处理16字节所以明文长度必须是16的倍数。实际业务里的字符串长度千奇百怪这就需要填充。最常用的填充方式是PKCS#7差n个字节到16的倍数就在末尾补n个值为n的字节。比如明文长10字节差6字节就补6个0x06明文刚好16字节则额外补16个0x10。为什么要多补一整块因为解密时看到末尾的填充值就能知道该去掉多少个字节如果没有额外补块恰好16字节的明文会无法区分没填充和填充了0个字节。function pkcs7Pad(data) { const padLen 16 - (data.length % 16); const padded new Uint8Array(data.length padLen); padded.set(data); padded.fill(padLen, data.length); return padded; } function pkcs7Unpad(data) { const padLen data[data.length - 1]; return data.subarray(0, data.length - padLen); }光有填充还不够。如果每个16字节块都用同一个密钥独立加密ECB模式那么相同的明文块会产生相同的密文块攻击者观察密文就能发现规律。所以实际项目里更常用CBC模式第一个明文块先和一个16字节的IV异或再送入加密之后的每一块都和上一块的密文异或后再加密。IV不需要保密但不能重复。联调时IV的编码格式Hex、Base64还是直接用字符串也是高频踩坑点这个我放到第5章细说。4. 核心代码落地从轮函数到ECB/CBC模式4.1 先列几个必不可少的工具函数正式实现SM4之前我封装了一批基础工具函数Hex字符串和字节数组互转、UTF-8字符串转字节、字节数组转32位字数组、以及循环左移。这些函数看着不起眼但编码不一致导致的问题十有八九出在它们身上。function hexToBytes(hex) { const bytes new Uint8Array(hex.length / 2); for (let i 0; i bytes.length; i) { bytes[i] parseInt(hex.substr(i * 2, 2), 16); } return bytes; } function bytesToHex(bytes) { let hex ; for (let i 0; i bytes.length; i) { hex bytes[i].toString(16).padStart(2, 0); } return hex; }这里有个容易忽略的小坑toString(16)对小于16的值只输出一位所以必须用padStart(2, 0)补齐前导零。否则0x0F会变成f拼出来的Hex字符串少一位对方解析时就错位了。4.2 S盒、轮函数与线性变换的实现S盒是SM4唯一的非线性来源是一张公开的256字节查表。我没有把完整的256个值贴在文章里因为排版实在臃肿而且它就是一个常量你从GB/T 32907-2016标准附录、或者sm-crypto等开源实现的源码里都能直接复制。正式项目里我都是直接抄标准表存储成Uint8Array(256)。拿到S盒之后轮函数就可以写了const SBOX loadSBoxFromStandard(); // 256字节来源GB/T 32907-2016 表A.2 function tau(x) { const a0 (x 24) 0xff; const a1 (x 16) 0xff; const a2 (x 8) 0xff; const a3 x 0xff; return ( (SBOX[a0] 24) | (SBOX[a1] 16) | (SBOX[a2] 8) | SBOX[a3] ) 0; } function linearL(b) { return (b ^ rotl(b, 2) ^ rotl(b, 10) ^ rotl(b, 18) ^ rotl(b, 24)) 0; } function linearLPrime(b) { return (b ^ rotl(b, 13) ^ rotl(b, 23)) 0; } function sm4Round(x0, x1, x2, x3, rk) { return (x0 ^ linearL(tau(x1 ^ x2 ^ x3 ^ rk))) 0; }注意tau函数里SBOX[a0] 24可能产生负数但因为最后有 0兜底整体比特拼接结果依然正确。这在JS里是常见写法我已经踩过一遍可以放心用。4.3 密钥扩展的代码密钥扩展需要一个CK数组。CK是32个固定的32位常量标准文档里有明确表格。实际编码时我直接用数组常量保存这里就不一个个列了从标准或开源实现里复制即可。下面是我实现中密钥扩展的骨架const FK [0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc]; function keySchedule(mk) { const K new Uint32Array(36); K[0] (mk[0] ^ FK[0]) 0; K[1] (mk[1] ^ FK[1]) 0; K[2] (mk[2] ^ FK[2]) 0; K[3] (mk[3] ^ FK[3]) 0; const rks new Uint32Array(32); for (let i 0; i 32; i) { const t tau((K[i 1] ^ K[i 2] ^ K[i 3] ^ CK[i]) 0); K[i 4] (K[i] ^ linearLPrime(t)) 0; rks[i] K[i 4]; } return rks; }K数组用Uint32Array来存有个额外好处下标越界时会读到undefined而不是静默创建一个全局变量调试时更容易暴露问题。4.4 ECB与CBC模式的封装拿到轮密钥后加密一个128位块就很直接了。32轮迭代后把最后4个中间字反序返回这一步和第2章说的输出反序对应function encryptBlock(X, rks) { let x0 X[0], x1 X[1], x2 X[2], x3 X[3]; for (let i 0; i 32; i) { const next sm4Round(x0, x1, x2, x3, rks[i]); x0 x1; x1 x2; x2 x3; x3 next; } return [x3, x2, x1, x0]; } function decryptBlock(X, rks) { const rksReversed new Uint32Array(32); for (let i 0; i 32; i) rksReversed[i] rks[31 - i]; return encryptBlock(X, rksReversed); }CBC模式的封装主要是块间的链式异或。加密时每个明文块先和上一块密文异或解密时每块密文先解密再和上一块密文异或。注意解密时用的上一块密文是密文流里前一个块而不是解密后的明文function sm4CbcEncrypt(plainBytes, keyBytes, ivBytes) { const padded pkcs7Pad(plainBytes); const rks keySchedule(bytesToWords(keyBytes)); const iv bytesToWords(ivBytes); const out new Uint8Array(padded.length); for (let i 0; i padded.length; i 16) { const block bytesToWords(padded.subarray(i, i 16)); const xored block.map((v, j) (v ^ iv[j]) 0); const ct encryptBlock(xored, rks); wordsToBytes(ct, out, i); iv ct; } return out; }ECB模式更简单不需要IV每个块独立加密。如果对接方明确要求ECB直接跳过异或步骤即可。我个人会优先建议对方使用CBC安全性好一些。4.5 一个可直接套用的字符串加解密示例最后拼一个完整的调用示例。日常业务里无论前端还是后端传的大部分都是字符串所以最外层做一次UTF-8编解码。浏览器环境可以直接用TextEncoder/TextDecoderNode现代版本同样支持const encoder new TextEncoder(); const key encoder.encode(1234567890abcdef); // 16字节密钥 const iv encoder.encode(abcdef1234567890); // 16字节IV const data encoder.encode(你好SM4); const cipherBytes sm4CbcEncrypt(data, key, iv); console.log(bytesToHex(cipherBytes)); const plainBytes sm4CbcDecrypt(cipherBytes, key, iv); console.log(new TextDecoder().decode(plainBytes)); // 你好SM4这里有个关键约定密钥和IV的长度必须正好16字节。很多新手会直接传一个任意长度的字符串当密钥结果报错或者结果和对方对不上。SM4的密钥就是128位16个字节不会多也不会少。5. 跨端联调踩坑实录结果对不上时按这个顺序查5.1 第一步永远是跑标准测试向量我每次写完一个加密实现后的第一件事不是去联调而是先跑一遍国标里的标准测试向量。SM4标准提供一个固定的示例密钥和明文都是0123456789abcdeffedcba9876543210加密输出的密文应该是681edf34d206965e86ba5e010f5a75df。只要这个测试向量能对上说明分组逻辑、轮函数、密钥扩展这些核心全部正确。为什么这个测试向量这么重要因为它把问题范围直接切开了核心算法错后面所有联调都是白忙活。只要向量通过后续再出问题基本可以锁定在编码格式、模式选择、填充约定这些外围因素上。我把这个习惯带到所有加密对接里节省了无数个排查夜晚。把测试向量写成一个自动化用例也很简单比如const key hexToBytes(0123456789abcdeffedcba9876543210); const plain hexToBytes(0123456789abcdeffedcba9876543210); const cipher sm4EcbEncrypt(plain, key); console.log(bytesToHex(cipher)); // 681edf34d206965e86ba5e010f5a75df如果你手里的基础实现S盒取自开源库这一步几乎必然通过。如果通不过优先检查S盒是不是抄错了、循环左移是不是用了带符号右移。5.2 中文编码String直接转byte的灾难现场最常见的联调失败原因其实是中文编码而不是算法本身。很多人的第一版代码会这么写str.split().map(c c.charCodeAt(0))把一个字符串直接拆成单字节数组。对于纯英文、纯数字的明文这样碰巧能对上一旦出现中文字符charCodeAt会得出大于255的值一个中文字被拆得七零八落密文自然和对方完全不同。正确做法是先用UTF-8编码把字符串转成字节数组再送去加密。JS端的TextEncoder默认就是UTF-8所以只要别手动做charCodeAt一般不会踩坑。Java后端那边也统一用UTF-8字符串转byte数组两边只要都强调先UTF-8再加密中文问题就消失了。我强烈建议在联调文档里写清楚这句话明文按UTF-8编码后进行SM4加密。这句话能省掉你和后端同事各自查半天编码的宝贵时间。5.3 模式、填充、IV编码三连问如果测试向量过了、中文也没问题但对接还是对不上这时候就把下面这几个问题按顺序问一遍对方用的是ECB还是CBCECB不需要IVCBC必须传输IV两者结果完全不同。填充用的什么常见是PKCS#7但也有NoPadding。如果你默认PKCS#7、对方默认NoPadding且明文长度正好是16的倍数两边结果恰好一致一旦明文长度不是16的倍数结果立刻分叉。IV以什么编码传输有的系统给Hex字符串有的给Base64有的干脆作为普通ASCII字符串。这三种方式即使内容一样实际参与异或的字节也可能不同。最典型的情况是对方把IV写成了0123456789abcdef这种Hex形式但某个字段约定里又声明是Base64两边各算各的前16字节密文全部对不上。我整理了一张排查对照表遇到结果不一致时可以照着看现象优先检查排查思路密文整体都对不上S盒、轮函数、密钥扩展先跑标准测试向量前几个块对后面错CBC模式的IV核对IV编码与块链逻辑明文是16倍数时对否则错填充方式PKCS#7是否一致是否一方用了NoPadding英文对、中文错字符串编码UTF-8是否统一和在线工具比不同密钥/IV的解析方式确认Hex还是字符串、大小写、0x前缀5.4 关于前端密钥安全的实话聊到这里必须说点实话纯JS实现的SM4在浏览器里跑密钥一定意义上等于公开的。任何能看到你前端代码的人在开发者工具里打断点、翻source文件都能把写死的密钥找出来。这跟算法本身无关任何前端对称加密都有这个通病。所以你得清楚这类实现的定位它主要用来防误操作、防明文数据在日志和抓包里裸奔或者满足合规要求里传输过程加密的检查项。真要防一个技术能力在线的人光靠前端SM4是不够的。更合理的安全模型是HTTPS保证通道安全密钥由后端按会话或用户动态下发而不是硬编码在前端代码里敏感操作配合签名和风控逻辑。如果对接方对安全等级有硬要求建议直接把这些限制讲清楚别让产品以为前端加了密就等于高枕无忧。6. 性能优化与工程化加工6.1 预分配轮密钥与避免无谓拷贝SM4的密钥扩展只需要在拿到密钥时算一次之后32轮轮密钥可以一直复用。我第一次写封装时没注意每次加密一个块都重新跑一遍keySchedule导致大批量加密时性能明显下降。优化方式很简单密钥扩展的结果作为参数传入或者直接缓存到闭包里。解密时的密钥反序也是一样的道理。不要每解密一个块都new一个Uint32Array再倒序拷贝而是初始化时就把反序后的轮密钥数组存好。这个优化虽然不复杂但在循环里省掉的是一次完整的数组创建和32次赋值批量处理几万条数据时差距非常明显。6.2 用预计算查表代替逐位循环如果数据量再上一个量级还可以用空间换时间的思路做预计算查表。SM4的T置换包含S盒和非线性L变换按字节拆分时可以写成L(τ(a0 || a1 || a2 || a3)) L(τ0(a0)) ⊕ L(τ1(a1)) ⊕ L(τ2(a2)) ⊕ L(τ3(a3))因为L是线性变换异或和循环左移都能拆开。这样我可以预计算4张表每张记录单个字节对32位输出的贡献每轮加密时只需4次查表加4次异或代替原来的4次S盒查表加5次循环左移异或。对于纯JS实现来说这种查表方式能省下不少位运算开销尤其在低端设备的WebView上立竿见影。不过这个优化也有代价4张表各256项32位值总共约4KB内存对现代浏览器来说可以忽略但对内存极度敏感的嵌入式场景可能需要斟酌。我的建议是先跑通功能再按实际瓶颈决定要不要上查表优化。6.3 工程化的几个建议再分享几个项目落地时的经验。一是把核心算法和外围编码工具分开成两个模块核心的SM4轮函数、密钥扩展不依赖任何平台API方便单元测试外围的Hex、Base64、UTF-8转换按环境适配。二是加上类型声明TS项目里直接引入就能享受类型提示避免字符串和字节数组的误用。三是可以在Web Worker里执行大批量加密避免阻塞UI线程。四是用单测覆盖标准测试向量和边界情况比如明文为空、明文恰好16字节、密钥全零等这些边界最容易出问题。我自己在实际项目中还会保留一个和开源库对拍的脚本随机生成密钥和明文用sm-crypto和自己实现分别加密比对结果。只要随机跑几千组不出现差异对核心实现的信心就会非常足。这个方法比肉眼审代码可靠得多。另外补充一个使用技巧如果对接系统同时支持SM4和AES而对方并不强制要求国密我一般会优先选AES。不是因为SM4不好而是AES的GCM模式在跨语言、跨平台上支持更成熟天然带完整性校验处理起来省心。但遇到明确要求SM4的场景那就老老实实用这份纯JS实现核心逻辑我已经尽量拆开了按需裁剪应该不困难。

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

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

免费获取报价 →
↑