
1. 为什么前端项目需要SM4从一次真实对接说起先讲个我踩过的坑。前阵子某内部管理系统要做数据加密改造后端明确要求前端提交的敏感字段必须用SM4加密后再传输。当时我下意识想用RSA结果被架构师一句“RSA慢且密文膨胀太厉害我们统一走SM4”给顶了回来。查了半天文档、翻了无数帖子才发现国产加密算法在浏览器端的落地并没有想象中那么顺手——不是缺库而是大多教程停留在“调一个encrypt函数就完事”的程度对密钥格式、填充模式、IV参数、中文字符这些细节完全没讲。后来我干脆把项目里的SM4模块重构了一遍也顺手沉淀出了这篇东西。先说结论SM4是国密标准里的对称分组算法分组长度128位密钥长度也是128位加解密速度快、硬件开销小非常适合做大数据量或高频场景下的字段加密。在Vue项目里实现SM4核心就是三件事选对库、定好参数、封装成可复用的工具模块。这篇博文会从算法本身讲起再给出一套能直接抄进项目的完整方案最后把编码、填充、并发这些我实际碰到的问题一并列出来。这篇文章适合谁看如果你正在做政务、金融、企业内部系统或者任何有“等保合规”要求的前端项目又或者你只是想把SM4作为面试亮点写进简历都可以照着这篇走一遍。基础要求很低懂Vue的基本语法、知道什么是npm install就够用了。2. 动手前的底层认知SM4算法到底在加密什么2.1 国密SM4的核心参数与设计思路SM4其实是分组密码里非常“标准”的一位选手。它一次处理16字节128位的数据块密钥同样是16字节。之所以强调“分组”是因为它不像流密码那样逐字节加密而是先把明文切成一截截固定长度的块每块独立加密后再拼起来。这个特性直接决定了加密时对数据长度有严格要求——如果明文不是16字节的整数倍就得做填充。SM4的结构是32轮非平衡Feistel网络。听不懂没关系你只需要记住它每一轮都会用轮密钥做异或和查表操作轮密钥又由原始密钥通过密钥扩展算法生成。这种结构的好处是硬件实现非常高效在嵌入式设备上跑得动在浏览器里自然也不存在性能瓶颈。实际测试下来用JavaScript实现加解密一段1KB的JSON耗时基本在毫秒级用户完全无感。它和AES有个相近之处就是都靠“扩散”和“混淆”来保证安全性——明文里哪怕只改一个比特密文也会面目全非。这也是我推荐直接用它做业务字段加密的原因安全性足够又符合国内合规要求。2.2 ECB、CBC与PKCS#7填充这些参数不能再靠猜搞SM4绕不开三个参数模式、填充、IV。我见过不少同学在GitHub上找到一段代码参数抄过来线的另一头是后端Java两边密文对不上于是怀疑库有问题。其实大概率是参数没对齐。ECB模式最简单每块明文独立加密同样的明文块永远得到同样的密文块。好处是实现简单、支持并行坏处是明文规律会体现在密文规律上安全性弱一些。如果加密的数据量小、没有规律性比如单个手机号或身份证号字段ECB倒也不是不能用。CBC模式则多了一个“链式反馈”的过程。第一个明文块先跟一个随机生成的初始向量IV异或再加密后面的每个明文块都先跟上一块的密文异或再加密。这就让相同的明文块在不同位置会产生不同密文安全性明显更好。代价是需要额外约定IV否则两端解不出来。PKCS#7填充是解决“最后一组不满16字节”的标准做法。它会在末尾补足差值的字节每个字节的值就是缺几个字节的数量。比如明文最后还剩5个字节就满了那就补5个0x05如果明文刚好是16的整数倍它还会额外补整整16个0x10。这个设计保证了解密时可以准确知道哪些是填充、该去掉多少。我个人的建议是如果项目里没有历史包袱优先选CBC PKCS#7 随机IV的组合。后面给的代码也按这个标准来。2.3 对称加密在前端的边界的边界密钥不能裸奔这里必须泼一盆冷水。SM4是对称加密加密和解密用同一个密钥而你写的是前端代码本质上等于把密钥放到了用户浏览器里。任何人在前端控制台里翻一翻网络请求、找一找打包产物里的明文变量都能把密钥捞出来。所以SM4在前端的定位是“防君子不防小人”的传输混淆真正要防破解还得靠后端配合比如只加密摘要、密钥走协商接口、敏感数据不入前端等。但话说回来合规改造很多时候要的就是“非明文传输”这四个字。SM4在前端能解决审计和抓包层面的暴露问题至于更严格的场景务必要做前后端分离密钥管理——前端存的是动态获取的会话密钥而不是写死的静态密钥。这一点我会在封装工具时体现。3. 工具选型为什么我最后锁定了这个库在选择SM4的JavaScript实现时我先后试过三条路。第一条是引C语言的WebAssembly方案性能确实猛但工程化成本也猛得配编译链对大多数Vue项目来说太过于重。第二条是自己照着文档手写加密逻辑这个只适合练手生产环境容易在一不小心之间写出不可逆的Bug排查成本极高。第三条是用现成的npm包这也是我最终采用的做法。对比了市面上几款支持SM4的库我最终锁定了sm-crypto。它的优势很直观API设计简洁encrypt和decrypt都是同步方法不必处理async状态。同时支持SM2、SM3、SM4后续如果业务要升级到非对称加签体系可以平滑扩展不用换依赖。仓库维护活跃我在某个Issuer里提过一个大端字节序的问题隔了几个版本就被修复了。不过这个库有一个版本兼容性的大坑——较新版本对Webpack的默认配置不太友好如果不做额外处理直接import旧包build时大概率报错。后面会专门给出处理方式。安装命令很简单npm install sm-crypto如果要兼容老项目也可以先用2.x版本因为API更稳定且没有ESM相关历史问题。4. Vue工程内的SM4工具模块封装从零到能落地4.1 创建工具文件与初始化配置我习惯在项目里维护一个src/utils/目录所有加解密相关代码独立成模块不散落在组件内部方便后续统一管理。创建一个crypto.js文件先把配置项固定下来import { sm4 } from sm-crypto // 项目统一定义的密钥实际生产中建议由后端动态下发至前端内存 const CRYPTO_KEY 0123456789abcdeffedcba9876543210 // IV 向量CBC模式下使用固定16字节 const CRYPTO_IV 1234567890abcdef // 密钥默认是utf8字符串需要转成字节数组 const key StringToByte(CRYPTO_KEY) const iv StringToByte(CRYPTO_IV)这里的密钥长度必须是16字节也就是32个十六进制字符或16个ASCII字符。我见过有人直接用中文当密钥长度瞬间就超出16字节解密端必然对不上。写一个把字符串转字节数组的工具函数function StringToByte(str) { const bytes [] for (let i 0; i str.length; i) { const c str.charCodeAt(i) bytes.push(c 0xff) } return bytes }注意这里取的是字符码的低8位对于英文和数字没问题中文会截断。所以要约定密钥里不能包含中文。4.2 加密与解密函数的真正实现核心API来自sm-crypto加密函数固定接收(msg, key, options)三个参数其中options里需要声明模式、IV和输出类型。解密对应地传入密文、密钥和同样的模式配置。export function encryptBySM4(plainText) { if (!plainText) return const utf8Text encodeURIComponent(plainText) const encrypted sm4.encrypt(utf8Text, key, { mode: cbc, iv, padding: pkcs#7, output: base64 }) return encrypted } export function decryptBySM4(cipherText) { if (!cipherText) return const decrypted sm4.decrypt(cipherText, key, { mode: cbc, iv, padding: pkcs#7, output: base64 }) return decodeURIComponent(decrypted) }请注意我在加密之前做了一次encodeURIComponent(plainText)这一步很关键。原因在于库内部处理字符串时默认会把中文字符按UTF-8逐字节编码。可是它对“字符长度”的判断和JSON.stringify的行为并不完全一致输入一个包含中文的字符串某些场景会出现填充错位或解密乱码。encodeURIComponent会把中文字符转成百分号编码的ASCII字符串这样进入加密函数的就是纯ASCII字节流填充计算就会稳定得多。解密端记得要反向decodeURIComponent。你可能会问那直接传字符串为什么有时候也正常因为库做了内部修复但不同版本处理方式不同有升级风险。做过一轮完整回归测试后我选择了提前转义的写法一劳永逸。4.3 在Vue组件里安全使用这套工具工具模块封装好之后在组件里使用非常直接template div input v-modelrawPhone placeholder输入手机号 / button clickdoEncrypt加密并提交/button p加密结果{{ encryptedValue }}/p /div /template script setup import { ref } from vue import { encryptBySM4 } from /utils/crypto const rawPhone ref() const encryptedValue ref() function doEncrypt() { encryptedValue.value encryptBySM4(rawPhone.value.trim()) // 这里把 encryptedValue 当作接口请求参数提交给后端 } /script需要特别说明的是不要把加密过程拆散写在每个页面里。统一走encryptBySM4的好处是一旦算法参数需要调整比如IV要改成动态获取只改工具文件全局生效。我在重构前就是吃了“页面里直接调sm4.encrypt”的亏整整查了12个文件才改完。另外建议在main.js里只注册工具模块为全局属性不要直接引入sm-crypto全局挂载。否则每个页面都会重复加载大段加密逻辑影响首屏性能。4.4 与后端Java对接时的联调要点前端加密做得再好后端解不开也白搭。SM4在跨语言对接时最容易出问题的地方是编码、模式和填充。这里列一份我实测有效的对齐清单对接项前端建议后端建议密钥32位十六进制字符串用Hex解码成16字节模式CBCCBC/PKCS5PaddingIV16字节ASCII字符串用ASCII转16字节密文编码Base64Base64解码明文编码UTF-8经URI转义UTF-8填充PKCS#7PKCS5PaddingJava中等价这里有个Java端经常出现的理解误区PKCS#5和PKCS#7在Java的AES/CBC场景下是等价的因为JCE内部对PKCS5Padding实际执行的就是PKCS#7逻辑。所以后端配置里写“PKCS5Padding”完全没问题前端这里用“pkcs#7”也对得上。密钥的下发方式如果可以尽量做成“每次会话开启时后端返回前端会话级缓存”而不是前后端各写一份死配置。这样一来即使前端密钥泄露也只影响单次会话不会波及全库数据。5. 构建配置解决Webpack Vite下的兼容坑5.1 处理sm-crypto在Vue CLI下的编译报错前面提到过版本兼容问题。现象是执行npm run build时控制台报错Module not found: Cant resolve crypto或Cant resolve buffer原因是sm-crypto内部引用了Node核心模块crypto和buffer但浏览器环境本身没有这些模块。Vue CLI基于Webpack构建需要显式配置fallback。在vue.config.js里加上这段const webpack require(webpack) module.exports { configureWebpack: { resolve: { fallback: { crypto: require.resolve(crypto-browserify), buffer: require.resolve(buffer/) } }, plugins: [ new webpack.ProvidePlugin({ Buffer: [buffer, Buffer], process: process/browser }) ] } }然后补装依赖npm install crypto-browserify buffer process5.2 Vite项目下的对应配置如果项目用的是Vite配置方式稍有差别。在vite.config.js里加入import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], define: { global: globalThis }, resolve: { alias: { crypto: crypto-browserify, buffer: buffer/ } } })global: globalThis这一行在Vite 3以上的版本尤其重要因为很多Node遗留包依赖global变量而浏览器环境里只有globalThis。不设置的话启动开发服务器倒没问题一打包就可能报global is not defined。这两套配置我都在实际项目里验证过按需取用就好。如果遇到process is not definedVite方案里也可以显式提供define: { process.env: {}, global: globalThis }6. 沉浸式实操一个身份证信息加解密的完整Demo前面讲了不少底层逻辑这节咱们直接走一遍真实业务场景。我拿“用户修改身份证号码”这个功能来做演示流程包括用户输入身份证号 → 前端加密 → 构造请求 → 后端响应 → 前端解密回显。同时我会指出过程中最容易踩的三个坎。6.1 定义类型与最小依赖先定义请求参数的类型方便后面维护// types.js export class ChangeIdCardRequest { constructor({ userId, idCard }) { this.userId userId this.idCard idCard // 这里必须是SM4加密后的密文 } }6.2 组装参数并加密在custInfo.js里写请求函数import { encryptBySM4 } from /utils/crypto import { ChangeIdCardRequest } from ./types import request from /api/request export function changeIdCard(userId, idCard) { const encryptedIdCard encryptBySM4(idCard) const payload new ChangeIdCardRequest({ userId, idCard: encryptedIdCard }) return request.post(/api/user/changeIdCard, payload) }到这里基本就能联调了。但在真实项目中身份证号往往不是单纯用户输入的字符串而是从表单里带过来的中间可能夹着不可见字符比如\n或空格。所以encryptByIdSM4内部我会加一步String.prototype.trim()操作保证加密前后长度稳定。6.3 解密响应数据示例如果后端把密文放在响应体的data.idCardCipher字段中前端回显协议可能是这么写export function loadIdCard() { return request.get(/api/user/idCard).then(res { if (!res || !res.data || !res.data.idCardCipher) return return decryptBySM4(res.data.idCardCipher) }) }这里有一个容易被忽略的点后端返回的密文如果经过URL编码或JSON转义前端拿到后可能带着号Base64解码时会把当成空格处理从而解密失败。遇到这种情况在解密前先做个复原function normalizeBase64(str) { return str.replace(/ /g, ).replace(/-/g, ).replace(/_/g, /) }根据经验这个边界问题在移动端网关环境偶尔会出现十分隐蔽。6.4 实际操作中踩过的三个具体坑坑一密钥中间多了一个空格。密钥从后端配置中心拉过来时如果配置文件里写了key 0123... 末尾换行或空格会被带进字符串。前端直接把这串带空格的文本转字节数组后端的Hex解码结果自然不一致。解决方案是统一对密钥做trim()和正则清洗比如只保留十六进制字符。坑二中文姓名解密后变成URI编码。前面前面讲了通过encodeURIComponent来规避中文字符问题但如果在某个节点漏了decodeURIComponent解密出来的串就全是%E5%BC%A0%E4%B8%89这种百分号格式。排查时先看接口请求里的明文长度再看解密后的串是否包含%基本能秒定位。坑三CBC模式下IV传了32字节。库对IV长度没有给出友好报错只会默默截取或最终密文解析失败。IV必须是16字节。我一开始图省事直接从随机数生成器里取了一份32位字符串当IV用结果怎么解都不对。后来统一规定IV与密钥一样都是十六进制字符串16字节才彻底扫清问题。7. 常见问题与排查技巧实录7.1 解密抛异常或返回空遇到这类问题我习惯按这个顺序排查确认密钥完全一致逐字符对比或打印bytes比较前后端数组值。确认模式与IV一致CBC模式只要有一边的IV不同解密第一组数据就出错。确认密文没有网络层转义用在线Base64解码工具解一次看是不是标准的16字节倍数密文。确认填充模式一致PKCS#7和ZeroPadding混用解密结果要么报错要么末尾多出一堆\u0000。确认字符编码密文从后端传回来时若JSON里被转成\u002B需要先解转义再Base64解码。7.2 同一明文每次加密结果完全不同是不是Bug不是。如果项目用了CBC模式且IV是随机生成的那么同一明文每次加密得到不同密文这是特性不是Bug。但要注意如果IV是随机生成的必须把IV随密文一起传给后端否则后端无法解密。我实际采用的做法是IV固定写在配置里或由后端接口下发密文格式约定为“IV密文”方便以后扩展。7.3 向后端传密文后字段超长SM4加密后的Base64密文长度约是明文的1.33倍再加上Base64填充符号比普通Base64存储多不了多少。但如果你用encodeURIComponent整个密文注意要把%和当普通字符处理接口用JSON传参就没影响。我用form表单提交时遇到过一次超长问题处理办法是改成JSON体传参。7.4 性能与内存占用连续加密大量字段时怎么优化SM4的JS实现并不慢但如果你在一个列表页里一次性加密几十条记录每个记录又包含多个字段那就不能逐个调用顶层函数了——每次调用都要重新做密钥扩展和循环结构初始化浪费明显。我建议把sm4.encrypt在工具模块里做成一个实例化后的对象比如const sm new Sm4(key, mode, iv)然后反复调用sm.encrypt省去重复初始化的开销。加密过程避免在forEach里做JSON.stringify重复制造大对象。数据量大时用Web Worker放到子线程里跑主线程的输入框就不会卡。7.5 打包体积过大怎么瘦身sm-crypto本身差不多几十KBgzip后会更小但有些项目对首屏体积很敏感。这时可以按需引入。库默认导出其实是SM2、SM3、SM4的合集若只用SM4可尝试改用子路径加载import { sm4 } from sm-crypto/src/sm4但这个引入方式有一定风险因为内部文件路径不是官方公开API版本升级后可能失效。另一种更稳的思路是直接给Webpack配optimization.splitChunks把sm-crypto单独打成chunk让它在首屏不阻塞加载。项目不追求极端优化的话这条参考一下就好。8. 从单机工具到工程化模块我做过的几次重构总结最开始我也觉得SM4不就是一两个函数的事吗直到在一个老项目里把加密逻辑直接写进了.vue文件后来需求要从ECB换CBC、密钥要从配置中心拉取才发现这玩意儿必须按工程化标准来设计。下面是我重构三轮后沉淀下来的几个原则。8.1 原则一参数必须配置化不能散落在业务里密钥、IV、模式、输出类型这四类参数应该集中放在一个sm4.config.js里或者用外部环境变量而不是在每个调用处重新指定。这样从全局视角加秘钥审计、定期轮换时会非常高效。环境变量方式可以这样写const config { key: process.env.VUE_APP_SM4_KEY, iv: process.env.VUE_APP_SM4_IV, mode: cbc, padding: pkcs#7 }.env文件里则对应存放十六进制字符串。注意这些配置打包后依然在产物里可见所以别放生产密钥生产密钥应走登录接口动态下发。8.2 原则二工具模块只做“加密”和“解密”不做业务我在工具模块里没有引入任何axios或路由逻辑它只负责输入输出。业务需要判断密文是否为空、是否需要缓存都在组件或状态层处理。这样职责清晰测试起来也简单——纯粹的函数都能写单测。8.3 原则三留好扩展位将来可能需要SM2合规业务经常会出现“登录用SM2、传输用SM4、完整性校验用SM3”的综合需求。所以我建议工具模块里把命名空间规划好// crypto.js export { encryptBySM4, decryptBySM4 } from ./sm4 // 后续可以直接挂出 sm2 模块而不用改引用处8.4 单测要点别只测英文给加密模块写单测时一定要覆盖这几类输入测试场景数据示例预期行为纯英文字符串HelloSM4!可回显中文字符串张三2024可回显且无URI乱码空字符串返回空串不报错长度为16整数倍的串0123456789abcdef正确填充特殊符号串%/密文与解密正常超长字符串超过16KB循环拼接加解密速度可接受单测命令用npm run test:unit框架建议Vitest或Jest都行。在主流的组件测试环境里只要Mock掉sm-crypto的耗时调用即可。9. 加密方案之外的三个提醒合规、日志与密钥轮换9.1 别把密钥写在代码里交付出去我之前接外包时经常看到交付物里的main.js顶部赫然写着const key 9876...。这种交付方式在“代码审计”环节会直接拉胯。尽量把密钥放到后端配置中心或接口响应中前端动态获取后只在运行时保留在内存里。至少也要做到在仓库配置里用环境变量占位。9.2 日志和监控里不要打印明文即便前端加密了如果发出去的请求体里有idCard: E8A9...这种高度敏感的密文而日志平台把请求体记录下来了那等于是把密文留档。只要有密钥离线就能解密。所以上线前务必在日志框架里过滤掉这类字段。9.3 密钥轮换策略一个常被忽略的坑对称加密密钥不能永远不变。如果密钥轮换了历史数据怎么办建议后端在设计表结构时预留version字段每条敏感数据记录它使用的是哪版密钥。前端收到新密钥后可以对存量数据进行一次“解旧 加密新”的迁移。前端工具模块里可以加一个getKeyVersion()方法配合接口下发的版本号动态切换密钥这样就不用停服改代码。10. 我在生产环境打磨这套方案的个人体会方案落地已经跑了大半年中间也确实见过上线一周后因为同事误把mode: cbc写成了ecb导致新老数据互相解不开的情况。这种事只有在真实业务里出现一次才会真正理解加密参数统一管理的重要性。其实很多非对称加密、签名体系的基础逻辑是相通的SM4只是其中一环。作为前端我们对算法细节的掌控不需要到“写轮函数”那个级别但必须清楚每一层封装在干什么、参数对齐的约束点在哪里。等到后端同事跑来问“你这段密文到底是Base64还是Hex”的时候你能直接给出明确的答案这就从“会用工具”进阶到“懂方案”了。如果你现在正准备把SM4集成进Vue项目我的建议很直接先拿这篇的Demo搭一条最小链路跑通再去填业务逻辑。你别一上来就全量替换先选一个低频接口做试点把接口日志、后端日志、解密回显三端全流程验证好再铺开不迟。加密这行最怕的就是——线上一切正常直到某一天某段数据永远解不回来。