ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue前端SM4国密加密实战:参数对齐与联调避坑

Vue前端SM4国密加密实战:参数对齐与联调避坑 1. 先想清楚前端引SM4到底在解决什么问题前端做国密加密这件事我最早是在一个对接类项目里碰到的。当时后端那边要求所有涉及用户信息的请求体必须走SM4加密前端不能用AES理由很简单——他们的密码机和安全网关只认SM4这条算法链路。我一开始也觉得这活儿不难无非是npm装个包、调个encrypt函数的事结果真正落地的时候密钥编码、分组模式、填充规则、IV传递这几件事前后端整整对了两天才跑通。这篇内容就是把这套流程完整拆开讲一遍。SM4是国密分组密码算法固定128位密钥、128位分组和AES同属对称加密家族胜在国内的密码设备、安全网关、合规链路普遍原生支持。在Vue项目里用SM4本质上是拿一个纯JS或者WASM实现的算法库把明文按块加密成密文再送到后端后端用同样的密钥和参数解回来。谁适合看这篇做过Vue项目、需要对接国密链路的前端负责前端安全方案选型的技术负责人还有一类是面试前想补对称加密实战细节的同学。我尽量不从教科书角度讲而是按我实际怎么装的、参数怎么定的、联调时怎么排错的这个顺序往下写代码都能直接抄。1.1 前端加密的真实边界它到底挡得住谁先泼一盆冷水。前端做加密很多人第一反应是这样用户数据就安全了这个认知是有偏差的。前端代码全部运行在用户浏览器里密钥只要下发到浏览器理论上就是可见的。所以前端SM4加密真正能挡住的是链路中间的旁路嗅探、日志系统里明文留痕、以及部分简单抓包工具的肉眼查看。它挡不住的是逆向了你的JS代码、拿到密钥的攻击者。那为什么还要做因为在很多实际的系统架构里加密是纵深防御的一层配合HTTPS、接口签名、时间戳防重放一起用。HTTPS解决的是传输层SM4解决的是应用层——比如你公司的网关日志、灰度链路、内部服务间的报文这些地方未必全在HTTPS保护范围内。明白了这个前提你才不会在方案设计上跑偏比如把希望全寄托在前端加密了所以后端可以不校验上那是给自己挖坑。还有一个很实际的场景有些项目要求敏感字段在数据库里也保持密文形态前端加密之后后端直接落库中间任何一跳都不出现明文。这种前端加密后端不解密直接存储的模式对前端来说反而更省心因为你只要保证加密参数稳定即可不用管解密。我做过的项目里这个模式占了将近一半。1.2 为什么选SM4而不是继续用AES技术上SM4和AES-128都是128位分组、128位密钥的对称算法安全强度在一个量级性能也接近。选SM4的理由通常不是它比AES强而是链路统一。第一个理由是密码设备兼容。不少单位采购的密码机、加密卡、安全网关出厂只烧了国密算法的固件你送AES过去它不认。第二个理由是合规审计某些行业的信息系统在建设时明确要求使用国产密码算法。第三个理由很朴素后端已经用SM4了前端自己再套一层AES等于多维护一套密钥体系运维成本翻倍。反过来说如果你的项目完全没有任何国密链路的约束团队成员对SM4也不熟那继续用AES-128-CBC配合HTTPS是更稳妥的选择没必要为了用而用。我见过有团队为了技术先进性引入SM4结果密钥管理没做起来硬编码在前端代码里加密了跟没加密区别不大。1.3 三种常见的落地形态实际项目里前端SM4一般出现在这三种地方处理方式差别挺大。请求参数加密最常见把某个敏感字段或者整个请求体加密后放进body。做法通常是封装一个加密函数在axios请求拦截器里统一处理。要注意的是加密后的密文长度会膨胀Base64后大约是原文的1.4倍留足字段长度。本地存储加密把token、用户偏好、临时缓存写进localStorage前先加密。这种场景密钥来源是个问题一般用设备指纹固定盐派生出一个key防的是别人直接翻你浏览器存储。真实价值有限但能过一些安全扫描。文件内容加密上传前把文件内容加密或者下载后解密。这种就涉及大文件分块的问题了纯JS的SM4跑几MB数据会明显卡顿后面我会专门讲怎么用Worker和分块来处理。2. SM4的核心参数这几点搞不明白联调必挂聊完场景必须把算法本身的几个关键参数说透。前后端联调失败90%的问题都出在参数不对齐上而不是算法实现有bug。我第一次对接就被ECB和CBC的区别坑了半天。2.1 128位密钥和128位分组是什么意思SM4的分组长度固定128位也就是16字节。什么意思呢就是你送进去的明文会被切成一块一块的16字节每一块单独走一遍32轮的轮函数变换。如果你送的明文是35字节前两块正好16字节最后3字节就不够一块了这时候就需要填充把它补到16字节。第2.3节会专门讲填充。密钥也是128位16字节。这一点新手最容易搞错sm-crypto这个库的key参数传的是16进制字符串也就是32个字符。我见过有同事直接传了一个16个字符的普通字符串当key跑起来不报错但后端死活解不开——因为库把每个字符当成了一个字节实际只用了16个ASCII字符虽然也是16字节但和后端理解的32个hex字符16字节不是一回事。这种问题排查起来特别费时间因为两边代码看着都没毛病。密钥的生成建议用crypto.getRandomValues(new Uint8Array(16))拿浏览器密码学安全的随机数然后转成hex字符串。千万别用Math.random拼那个随机性不够虽然算法本身不需要密钥特别强但既然是安全相关该做的还是要做到位。2.2 ECB、CBC、GCM三种模式怎么挑工作模式决定了每一块明文怎么和密钥结合。这部分的差异直接决定了你的密文长什么样。模式是否需要IV相同明文是否产生相同密文能否并行推荐度ECB不需要是相同块出相同密文可以不推荐CBC需要16字节IV否解密可加密不可常规首选GCM需要12字节IV否否支持时优先ECB模式最简单每块独立加密问题也最明显相同的明文块会得到相同的密文块。如果你加密的是一张图片ECB加密后还能看出原图的轮廓这就是所谓的ECB企鹅图典故。所以ECB基本只在调试阶段用生产环境不要上。CBC模式引入了初始向量IV第一块明文先和IV异或后续每一块和上一块的密文异或。这样相同明文在不同位置会得到不同密文。IV本身不需要保密但必须每次随机且不能重复使用。实际做法是前端随机生成IV把IV和密文一起拼起来传给后端后端按约定切出IV再解密。GCM是带认证的模式除了密文还会产出一个认证标签tag能防篡改。安全性比CBC好但sm-crypto对GCM的支持要看版本团队里版本不统一的时候容易出事。用之前先确认package-lock.json里锁定的版本到底支不支持别看到文档里有就以为能用。2.3 填充规则和编码格式两个隐形杀手填充解决的是明文长度不是16的整数倍这个问题。最常用的是PKCS#7缺N个字节就补N个值为N的字节。比如缺3个字节补0x03 0x03 0x03如果明文字数正好是16的倍数那也要补一整块0x10重复16次。这里有个概念要捋清PKCS#5和PKCS#7在16字节分组下是等价的。PKCS#5原本是为8字节分组设计的后来大家在实际实现里都直接用PKCS#7的写法处理16字节分组。sm-crypto里你传padding: pkcs#5或者pkcs#7内部走的是同一套逻辑不用纠结。编码格式这块陷阱更多。sm-crypto默认输出16进制字符串而后端很多实现默认输出Base64。这两者长度和字符集完全不同前端传hex、后端按base64解析必然报错。我的做法是统一约定密钥用hex字符串、IV用hex字符串、密文输出Base64。理由是Base64在JSON里更长但更紧凑同样的字节数Base64比Hex短一半关键是在URL和JSON里都不用转义。密钥这块还有一个容易忽略的点后端拿到的密钥可能是一个字符串它得先转成字节数组。Java里key.getBytes(StandardCharsets.UTF_8)和Hex.decode(key)出来的结果天差地别。如果前端传的是32个hex字符后端必须用hex解码用UTF-8解码会得到32字节SM4直接报密钥长度不合法。3. 在Vue项目里把SM4真正跑起来参数讲完了进入动手环节。我按Vue 3 Vite的组合写Vue 2 Webpack的项目在依赖引入那一节有点差别我会单独说明。3.1 依赖选择与环境准备npm上做SM4的库有好几个我实际用过的主要是sm-crypto和gm-crypt。sm-crypto更主流维护也更活跃还附带SM2和SM3如果你的项目三种算法都要用一个包就够了。# 安装依赖 npm install sm-crypto --save # 如果项目用的是Vue3 Vite建议同时装一下这个处理CommonJS兼容 npm install vite-plugin-commonjs --save-devVue 3 Vite项目里有个很典型的坑sm-crypto是CommonJS规范打包的Vite的开发服务器默认按ESM处理直接import { sm4 } from sm-crypto有时候会报require is not defined。解决办法有两个一是用动态导入配合optimizeDeps.include二是在vite.config.js里配置// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], optimizeDeps: { include: [sm-crypto] // 强制预构建转成ESM } })如果项目是Vue 2 Vue CLIWebpack 4以上基本能直接吃下CommonJS不太会遇到这个问题。但Webpack 5有个resolve.fallback的配置变化如果报crypto模块找不到需要在vue.config.js里加上resolve: { fallback: { crypto: false } }。提示不要用CDN引入sm-crypto。一是版本不受控二是加密逻辑走CDN意味着你的密钥处理代码暴露在第三方资源链路里安全审计上过不了。3.2 封装一个能复用的加密工具模块直接在每个组件里写sm4.encrypt()是很糟糕的做法密钥散落各处改一个参数要翻遍整个项目。我的习惯是在src/utils/下建一个sm4.js把所有细节收口进去。// src/utils/sm4.js import { sm4 } from sm-crypto // 加密配置实际项目中这些值应该从环境变量读取 const SM4_CONFIG { // 16进制字符串32个字符 16字节 key: import.meta.env.VITE_SM4_KEY || 0123456789abcdeffedcba9876543210, mode: cbc, padding: pkcs#7, output: base64 } /** * 生成16字节随机IV返回hex字符串 */ function generateIv() { const arr new Uint8Array(16) window.crypto.getRandomValues(arr) return Array.from(arr, b b.toString(16).padStart(2, 0)).join() } /** * 加密返回 iv:ciphertext 格式方便后端切分 */ export function encrypt(plainText) { if (plainText null || plainText undefined) return const iv generateIv() const cipher sm4.encrypt(String(plainText), SM4_CONFIG.key, { mode: SM4_CONFIG.mode, iv, padding: SM4_CONFIG.padding, output: SM4_CONFIG.output }) return ${iv}:${cipher} } /** * 解密接收 iv:ciphertext 格式 */ export function decrypt(payload) { if (!payload) return const [iv, cipher] payload.split(:) if (!iv || !cipher) throw new Error(密文格式不正确) return sm4.decrypt(cipher, SM4_CONFIG.key, { mode: SM4_CONFIG.mode, iv, padding: SM4_CONFIG.padding, output: string }) }这个封装有几个设计决策值得说一下。第一把IV和密文拼在一个字符串里用冒号分隔而不是分开传两个字段。好处是调用方只要处理一个变量不容易出现传了密文忘了传IV的问题后端解的时候按冒号切一下就行。第二output统一用base64前面讲过原因。第三密钥从环境变量读做一层兜底方便不同环境用不同密钥。关于密钥放环境变量这件事我得说清楚Vite的环境变量在构建时会被静态替换进产物VITE_开头的东西最终是明文躺在JS文件里的。所以这只是不要硬编码在业务代码里的工程规范不是真正的密钥保护。真要防得用后端下发密钥或者走非对称协商这部分超出本次范围。3.3 接进axios拦截器做请求自动加密封装好工具函数接下来是让它自动生效。我的做法是在axios的请求拦截器里判断URL命中了需要加密的接口就处理请求体。// src/utils/request.js import axios from axios import { encrypt } from ./sm4 const service axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 15000 }) // 需要加密的接口白名单用正则匹配 const ENCRYPT_WHITE_LIST [ /\/user\/profile/, /\/order\/create/, /\/payment\// ] service.interceptors.request.use(config { const needEncrypt ENCRYPT_WHITE_LIST.some(re re.test(config.url)) if (needEncrypt config.data) { // 整体加密成一个字符串字段 config.data { payload: encrypt(JSON.stringify(config.data)) } config.headers[X-Encrypted] SM4 } return config }, error Promise.reject(error)) export default service这里头有个坑值得专门提加密之后Content-Type还是application/json但body结构变了原来的业务字段全被包进了payload字符串里。后端如果用的是RequestBody直接映射成对象会解析失败。要么后端改成一个接收payload字段的包装类要么前端改成只加密某一个敏感字段、其他字段保持原样。我在项目里两种都做过看后端改造意愿。整体加密更彻底但后端改动大字段级加密改动小但要注意别漏字段。还有一个容易忽略的细节GET请求的参数在URL上请求拦截器里config.data是空的。如果GET也要加密得处理config.params把参数拼成字符串加密后作为一个q参数传后端解出来再拆。我一般建议接口设计上避免用GET传敏感数据改用POST能省很多事。3.4 大文本与性能优化该上Worker就上纯JS实现的SM4性能是个绕不开的话题。我实测过一组数据在普通笔记本的Chrome上数据量耗时约主线程表现1 KB 1 ms无感100 KB12 ms无明显掉帧1 MB130 ms轻微卡顿10 MB1.4 s明显卡死这个数据说明处理普通接口报文几KB完全不用担心但如果是加密上传文件的内容1MB以上就有感觉了10MB直接让页面失去响应。有一类场景特别容易踩这个坑上传图片前先把图片转成Base64再整体加密一张手机拍的照就是3到5MB加密的时候页面转圈好几秒。解决方案是把加密逻辑放进Web Worker。思路是把sm-crypto引入Worker文件主线程把明文通过postMessage发过去Worker加密完再发回来。// src/workers/sm4.worker.js import { sm4 } from sm-crypto self.onmessage function (e) { const { id, text, key, iv } e.data try { const cipher sm4.encrypt(text, key, { mode: cbc, iv, output: base64 }) self.postMessage({ id, success: true, data: cipher }) } catch (err) { self.postMessage({ id, success: false, error: err.message }) } }更进一步的优化是分块处理。CBC模式本身是串行的第N块的加密依赖第N-1块的密文没办法简单并行。但你可以把大文件切成多个独立的小段每段用一个独立的IV加密最后在文件头记录分块信息。这样就能利用多个Worker并行处理代价是密文结构变复杂需要和后端约定好分块协议。我之前处理一个20MB的日志文件导出加密切成16个块用4个Worker并行跑整体耗时从18秒降到5秒左右。注意Worker里也要引入sm-crypto这会让打包体积再增加一份。如果项目对首屏体积敏感可以用new Worker(new URL(./sm4.worker.js, import.meta.url), { type: module })的写法让打包工具把Worker拆成独立chunk用到的时候再加载。4. 前后端联调和Java侧对齐参数的实录前端自己加密自己解密怎么测都是通的真正的考验在和后端对接的那一刻。这部分我按实际踩过的顺序写。4.1 后端两种主流实现方式的参数差异Java侧做SM4主流有两条路Hutool和BouncyCastle。两者的默认参数不一样必须提前问清楚。Hutool的写法大致是这样// Hutool 方式 import cn.hutool.crypto.symmetric.SM4; import cn.hutool.crypto.Mode; import cn.hutool.crypto.Padding; // 方式一默认ECB SM4 sm4 SmUtil.sm4(keyBytes); // 方式二指定CBC和IV SM4 sm4 new SM4(Mode.CBC, Padding.PKCS5Padding, keyBytes, ivBytes); String plain sm4.decryptStr(cipherBase64);Hutool的SmUtil.sm4()默认走ECB、PKCS5Padding、输出hex。注意这三个默认值默认ECB对应前端必须用ECB默认PKCS5Padding在16字节分组下等于PKCS7这个没问题默认输出hex说明你前端要么也输出hex要么解密前先做一次hex解码。BouncyCastle的写法更底层一些// BouncyCastle 方式 Cipher cipher Cipher.getInstance(SM4/CBC/PKCS7Padding, BC); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, SM4), new IvParameterSpec(ivBytes)); byte[] plain cipher.doFinal(Base64.getDecoder().decode(cipherBase64));Cipher.getInstance的第一个参数是算法/模式/填充三元组这里必须和前端完全一致。SM4/CBC/PKCS7Padding和SM4/CBC/PKCS5Padding在后端可能都能跑但这依赖具体Provider的实现别赌。4.2 编码格式对齐表照着填就不会错我把最容易出错的几个参数整理成了一张表联调前双方对着填一遍能省掉大半的沟通成本。参数项前端sm-crypto后端Java常见错误密钥格式32位hex字符串Hex.decode(key)后端用UTF-8解码得到32字节IV格式32位hex字符串Hex.decode(iv)前端传base64后端按hex解密文编码base64Base64.decode()前端hex后端base64分组模式cbcMode.CBC前端cbc后端ecb填充方式pkcs#7PKCS5Padding一般无差异16字节块下等价明文字符集UTF-8UTF-8中文场景必须显式指定这张表里密钥格式那一行是我见过的最高频问题。原因是前端传的密钥看着像字符串后端开发下意识就写了key.getBytes()结果密钥变成32字节SM4初始化直接抛异常或者悄悄用了前16字节解出来的全是乱码。4.3 一次完整的联调排错过程说个真实的例子。当时前端按CBC模式加密把iv:cipher的格式传过去后端返回解密失败。我按下面的顺序查了一遍第一步确认密钥。我让后端打印了Hex.encodeHexString(keyBytes)和前端传的密钥字符串比对长度一致内容一致密钥没问题。第二步确认IV。后端打印IV字节数组的长度发现是32——他用UTF-8解码了我传的hex字符串每个hex字符变成一个字节32个字符正好32字节。这就是问题所在。改成Hex.decode(iv)之后IV长度变成16问题解决。第三步验证一个固定用例。我们约定了一个测试明文hello-sm4-test前端用固定密钥和固定IV加密把结果贴给后端后端本地跑一遍两边结果完全一致说明参数对齐了。这一步特别重要一定要用一个双方都写死的用例交叉验证别一上来就用真实业务数据测。第四步测中文。英文测通了不代表中文没问题。中文在UTF-8下是3字节会导致明文长度和英文不一样填充的字节数也不同。我用中文测试用例跑了一遍确认两边的字符集都是UTF-8没问题。这套流程走下来后续再遇到解密失败基本能在10分钟内定位到底卡在哪一环。5. 常见问题排查与实战避坑清单最后这部分是我攒下来的问题库按现象归类遇到了直接翻。5.1 解密失败速查表现象大概率原因排查动作报padding error或类似异常填充方式不一致或密文被截断检查padding参数核对密文完整长度解密出来是乱码但没报错密钥或IV不对打印密钥字节长度必须是16报密钥长度不合法密钥解码方式错误确认是hex解码不是UTF-8解码短文本能解长文本失败分组模式不一致长文本会跨多个块ECB/CBC差异暴露中文变问号或方块字符集不是UTF-8前后端都显式指定UTF-8第一次能解第二次失败IV被复用了每次加密都重新生成随机IV最后一条我要单独强调。IV复用在CBC模式下是严重的安全缺陷攻击者可以通过对比相同IV下的密文差异推断明文内容。我在代码审查中见过有人把IV写死在配置文件里理由是反正密钥才是关键。这种写法必须改掉每次加密生成新IV成本只是一次随机数生成。5.2 打包和兼容性相关的坑有几个问题和加密本身无关但实实在在会拦住你。第一个是打包后体积膨胀。sm-crypto依赖了jsbn做大数运算整个包压缩前大约100KBgzip后三四十KB。如果你的项目用了按需加载把加密模块放在异步chunk里别塞进首屏bundle。第二个是Vite构建时的CommonJS提示。构建日志里会出现CommonJS module detected之类的警告不影响运行但如果团队要求零警告就得在build.commonjsOptions里做处理。第三个是老浏览器的兼容性。crypto.getRandomValues在IE11下不支持如果项目还要兼容IE得引入polyfill或者降级到Math.random同时接受安全性的下降。现在大部分项目已经不背这个包袱了但接手老项目的时候要留意。第四个是微前端场景。如果你的Vue应用是嵌在qiankun这类框架里的子应用Worker的路径解析可能会有问题new URL(./sm4.worker.js, import.meta.url)在子应用里可能找错基路径。我遇到过一次最后是通过配置worker的format: es和调整base解决。5.3 密钥管理上别踩的线这一节是经验之谈也是我见过出问题最多的部分。不要把密钥写在组件里。哪怕是临时调试也不要const key xxx写在.vue文件里然后提交上去。git历史里翻出来太容易了而且删了也没用历史记录还在。不要让前后端用完全相同的密钥体系长期不变。理想做法是有一个密钥协商流程比如用SM2做密钥交换每次会话生成一个临时SM4密钥。这个改造量不小但如果你做的是涉及资金或者个人身份的系统值得投入。不要在前端做解密验证。有些实现是前端加密、前端再解密给自己看用来校验加密是否正确。这个逻辑除了多消耗一次CPU没有任何价值反而给攻击者提供了现成的解密函数可以参考。日志里不要打印明文或者密钥。包括console.log、错误上报、性能监控的埋点。我处理过一次线上事故前端错误监控SDK把异常对象的堆栈和上下文全量上报其中包含了加密前的用户信息最后是清理上报内容才解决的。6. 关于实际落地的一点个人体会这套东西我从第一次接触到现在前前后后在不同项目里做过四五遍最深的感受是SM4的算法实现本身从来不是难点难的是参数对齐和密钥管理这两件看起来很简单的事。我现在的习惯是只要项目涉及前端加密第一件事就是拉一个参数对齐清单把密钥格式、IV格式、分组模式、填充方式、密文编码、字符集这六项写清楚双方确认后再动手写代码。这个清单花十分钟能省掉后面两天的扯皮。另外一个习惯是永远准备一个固定输入输出的测试用例前端加密出来的字符串钉在文档里后端本地跑一遍比对对上了再进联调环境。如果让我给正在做这件事的人一句话建议那就是先把ECB模式跑通确认密钥和编码没问题再切到CBC加上IV。跳过这一步直接上CBC出问题的时候你没法区分是算法参数不对还是IV处理不对排查难度翻倍。至于后面的扩展方向如果你的系统对性能有更高要求可以研究一下用WASM编译的SM4实现在高并发或者大文件场景下比纯JS快好几倍。另一个方向是把加密逻辑和密钥管理解耦做成一个统一的加密服务层业务代码只调encrypt()和decrypt()底层用什么库、什么模式随时可以替换。这个分层做起来不难但对后期维护的帮助特别大我在最近一个项目里就是这么设计的后来从sm-crypto换成WASM实现业务代码一行都没改。
返回列表