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-model="rawPhone" placeholder="输入手机号" /> <button @click="doEncrypt">加密并提交</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字节 |
| 模式 | CBC | CBC/PKCS5Padding |
| IV | 16字节ASCII字符串 | 用ASCII转16字节 |
| 密文编码 | Base64 | Base64解码 |
| 明文编码 | UTF-8(经URI转义) | UTF-8 |
| 填充 | PKCS#7 | PKCS5Padding(Java中等价) |
这里有个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: Can't resolve 'crypto'或
Can't 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 defined,Vite方案里也可以显式提供:
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本身差不多几十KB,gzip后会更小,但有些项目对首屏体积很敏感。这时可以按需引入。库默认导出其实是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搭一条最小链路跑通,再去填业务逻辑。你别一上来就全量替换,先选一个低频接口做试点,把接口日志、后端日志、解密回显三端全流程验证好,再铺开不迟。加密这行最怕的就是——线上一切正常,直到某一天某段数据永远解不回来。