去年下半年接手的一个行业数据查询类项目,前后端约定所有列表接口的响应体都走密文传输,前端拿到数据之后先在本地解开、再交给状态管理渲染。项目本身不复杂,麻烦的地方在于它是个两年多的老仓库,webpack 从 4 升到 5 之后一直没彻底清账,直到这次要往里面塞一个基于国密 SM4 的解密模块,构建就直接炸了——终端里甩出一行Module not found: Error: Can't resolve 'crypto',本地 dev 起不来,CI 也跟着红。这篇文章就是把这套“数据解密 + 国密 SM4 + webpack 打包模块补齐”的完整处理过程摊开讲一遍,从我最初对需求的拆解,到库的选型,到 webpack 配置怎么补、补哪些、哪些其实不该补,再到联调阶段被 base64 和 padding 折腾的两天,最后是安全边界和打包体积的收尾。适合两类人看:一类是手里有类似密文接口、要在浏览器里做国密解密的前端;另一类是仓库里躺着一堆 Node 核心模块引用、每次构建都提心吊胆、想一次性把 webpack 模块补齐这件事弄明白的同学。我会尽量把“为什么这么配”讲清楚,而不是只丢一份能跑的配置让你抄。
1. 先把需求拆开:这个项目到底要解决什么
动手改配置之前,我在白板上把整条链路画了一遍,因为经验告诉我,Can't resolve 'crypto'这种报错最怕的就是头痛医头——你补了 crypto,它下一个报 stream,补了 stream 又报 buffer,来回折腾一下午,最后还是没搞明白自己的代码到底依赖了什么。所以第一步不是改 webpack,而是把“谁在用什么”这件事搞清楚。
1.1 数据链路还原:密文从哪儿来,到哪儿去
这个项目的接口形态是这样的:后端返回的 JSON 里,业务字段不是明文对象,而是一个字符串,字段名叫data,里面是 base64 编码的密文;同一个响应里还有一个timestamp和nonce。前端拿到之后,用约定的密钥和偏移量做 SM4 解密,得到一个 JSON 字符串,JSON.parse之后才是真正的业务数据。也就是说,前端在这里承担了一个“解密代理”的角色。
这条链路里有两个关键点值得先说清楚。第一,解密发生在浏览器内存里,意味着密钥最终一定会出现在用户机器的内存甚至源码里,这一点后面第 6 章会专门讲,先按下不表。第二,解密是同步的、纯 CPU 计算,没有网络请求、没有异步等待,所以它必须足够快——列表页一屏 20 条数据,每条都要解一次,如果单次解密超过 1 毫秒,滚动加载就会有明显的卡顿感。这两个约束直接决定了后面的选型:不能用需要加载 wasm 的异步方案,也不适合引入体积巨大的加密套件。
我当时做了一件事,把线上真实的一条响应体抓下来存成 fixture 文件,放在项目里当测试用例。这个习惯强烈建议你养成:解密这种“要么全对要么全错”的逻辑,有一个真实样本在手,排查效率会高一个数量级。后面所有关于 base64、padding、中文字符的坑,都是靠这个 fixture 复现和验证的。
1.2 为什么业务方选了 SM4 而不是常见的 AES
这个问题的答案其实不在我这边,但作为实现方必须理解,否则你会在选库的时候走偏。SM4 是分组长度和密钥长度都是 128 位的对称分组密码,和 AES-128 在结构定位上是对标的,都属于“用同一把钥匙加锁和解锁”的类型。项目方选择它,主要原因是合规层面有明确要求,数据在传输和落盘环节需要使用国密算法族,SM3 做摘要、SM4 做对称加密、SM2 做非对称。这是硬性约束,不是技术偏好,所以“能不能换成 AES”这个念头从一开始就不用有。
理解了这一点,选型的方向就很明确了:我需要的是一个纯 JavaScript 实现、体积可控、API 简单、支持 ECB 和 CBC 两种模式、能处理 PKCS#7 填充的 SM4 库。之所以强调“纯 JS”,是因为浏览器原生的 Web Crypto API 至今不支持 SM4,crypto.subtle.importKey传'SM4-CBC'会直接抛错,没有商量余地。这一点很多人第一次做国密前端时会踩:以为能像 AES 一样调原生 API,结果文档翻半天发现压根没这个算法。
注意:Node.js 环境是另一回事。Node 从 12 版本开始,底层 OpenSSL 就带上了国密支持,
crypto.createCipheriv('sm4-cbc', key, iv)在服务端是可直接用的。如果你的项目有 SSR 或同构需求,服务端和浏览器端可以走两套实现,这个后面 6.2 会讲。
1.3 “模块补齐”补的到底是什么坑
回到报错本身。webpack 5 做了一个在当年引起很大争议的决策:不再自动为 Node.js 核心模块注入 polyfill。webpack 4 时代,你在代码里写require('crypto'),打包器会默默地给你塞一个浏览器版实现进去,一切看起来很正常。webpack 5 把这个行为取消了,理由是——大多数前端项目其实根本不需要这些模块,自动注入只会白白增加包体积,而且会让开发者对自己代码的真实依赖产生误判。
于是问题来了:我这个老仓库里,除了新加的 SM4 库,还躺着几个年代久远的依赖,它们内部引用了crypto、stream、buffer、path这些 Node 内置模块。在 webpack 4 下这些引用被自动兜住了,升级到 5 之后就成了定时炸弹,平时没人动它就不报错,一旦你在业务代码里也引入了类似依赖,整条依赖链就被拉进构建图,报错集中爆发。
所以“模块补齐”这个动作的本质,是给 webpack 一份明确的映射表:当代码里出现crypto这个模块标识符时,去哪个 npm 包里找浏览器实现。它不是“修复 bug”,而是“把 webpack 4 时代的隐式行为显式地写回来”。想通这一点,后面配置里的resolve.fallback你就不会觉得是在抄玄学配置了。
2. 方案选型:库怎么挑,polyfill 怎么配
选型这一步我花了大半天,主要是在几个 SM4 实现之间做对比,顺便把 webpack 侧的补齐方案定下来。这里我把当时的对比过程和结论完整写出来,你可以直接拿这个结论去用,但更建议你理解对比的维度,因为换个项目约束条件就变了。
2.1 三款常见 SM4 实现的横向对比
我实际试过三种路线:一个是社区里用得最多的sm-crypto,一个是基于它封装的gm-crypt,还有一个是把 Java 端 BouncyCastle 的行为照搬、自己手写封装的方案。对比维度我列了五个:体积、API 友好度、模式支持、依赖情况、维护活跃度。
| 方案 | 压缩后体积 | API 风格 | 模式支持 | 依赖 | 适用场景 |
|---|---|---|---|---|---|
| sm-crypto | 约 30-40KB | 函数式,sm4.encrypt/decrypt | ECB、CBC | 无 Node 核心模块强依赖 | 大多数前端项目首选 |
| gm-crypt | 约 40-50KB | 面向对象,new SM4({...}) | ECB、CBC | 间接引入 sm-crypto | 需要统一配置对象的项目 |
| 自研封装 | 视实现而定 | 完全可控 | 按需 | 可能需要 crypto/buffer | 有特殊 padding 或编码要求 |
最后我选了sm-crypto,理由不复杂:它不强制依赖 Node 核心模块,API 足够简单,一个sm4.decrypt(cipher, key, { mode: 'cbc', iv })就完事,而且我查了它的源码,内部用的是纯数组运算,没有偷偷require('crypto')。这一点非常关键——很多“模块补齐”的坑,其实是你引入的第三方库自己带进来的。如果你选的库本身就依赖 Node 核心模块,那你在 webpack 里补的那一堆 fallback,本质上是在给别人的代码擦屁股,包体积白白增加。
gm-crypt我也试了,它的好处是把 key、iv、mode、cipherType 全部收进一个配置对象,代码读起来更整齐,适合一个项目里有多套加密参数的场景。但它内部还是依赖sm-crypto,等于多了一层封装和一点体积开销。如果你的需求就是“一个密钥、一种模式”,没必要上这一层。
提示:选库之前一定去看一眼
package.json的dependencies和源码里的require语句。我在一个项目里见过某加密库为了生成随机数,引入了整个crypto-browserify,直接让主包大了 200KB,这种亏吃一次就够了。
2.2 webpack 5 不再自动 polyfill 的前因后果
这一节稍微展开讲讲原理,因为理解了它,你配fallback的时候就知道每一行在干嘛。
webpack 的模块解析有一个概念叫“模块标识符到实现的映射”。当你的代码里写import crypto from 'crypto',webpack 会先判断这是不是相对路径,不是的话就去node_modules里找名为crypto的包。在 Node 环境里,crypto是内置模块,不需要安装,运行时直接可用;但浏览器里没有这个东西,打包器必须给它一个替代品,这个替代品就叫 polyfill。
webpack 4 的做法是内置一张表,表中列出所有 Node 核心模块和它们对应的浏览器实现包,遇到就自动替换。webpack 5 把这张表删了,改成让你自己通过resolve.fallback声明。同时官方提供了一个node-polyfill-webpack-plugin插件,可以一键把整张表加回来——但我个人不建议无脑用,原因很简单:它会把你项目里所有 Node 核心模块的引用全部补齐,包括那些你压根走不到的代码路径,包体积会明显膨胀。更精细的做法是逐个声明,只补真正触发报错的那几个。
另外还有一个容易被忽略的配置是ProvidePlugin。它解决的不是“模块找不到”,而是“变量找不到”。比如某些库直接用全局的Buffer或process,代码里没有 import 语句,webpack 自然也不会去做模块解析,这时候就需要ProvidePlugin主动把这些全局变量注入进去。这两个配置配合使用,基本能覆盖 95% 的补齐场景。
2.3 依赖清单与版本锁定
我最终装的东西不多,列一下:
npm i sm-crypto npm i -D crypto-browserify stream-browserify buffer processsm-crypto是业务依赖,后面四个是构建期的补齐依赖。注意crypto-browserify本身还依赖randombytes、create-hash等一串包,装完之后node_modules会多出几十个目录,这是正常的,不用慌。
版本上我做了一件事:把sm-crypto锁在次版本号,写在package.json里用精确版本而不是^。理由是加密库的行为变更风险比业务库高,万一某次小版本升级改了默认 padding 或者 hex/base64 的处理方式,线上会出现大面积解密失败,而且这种问题很难在测试环境复现,因为测试环境装的可能是旧版本。锁版本这个动作看起来保守,但在加密相关的依赖上,我认为是必要的。
3. 实操落地:从报错到跑通
这一章是完整的动手过程,我按当时的实际顺序写:先定位、再补配置、然后封装模块、最后用标准向量自证。每一步我都写了“为什么”和“踩到什么坑”,你可以按这个顺序复现。
3.1 第一步:定位到底是哪个模块缺失
报错信息通常长这样:
Module not found: Error: Can't resolve 'crypto' in '/project/node_modules/xxx/lib'注意in后面那个路径,它会告诉你到底是哪个包在引用。如果路径指向你自己的src目录,说明是你写的代码直接引用了;如果指向node_modules下某个包,说明是间接依赖。这两种情况的处理方式不一样:前者你其实可以考虑改掉自己的代码,后者只能补齐。
我当时的做法是用--stats输出一份详细构建报告,看看依赖图里到底有哪些模块被标记为 external 或者 unresolved:
npx webpack --stats detailed --json > stats.json然后在 stats 里搜crypto、stream、buffer这几个关键词,看它们分别被哪些模块引用。这一步做完,我心里就有数了:crypto是被一个老版本的哈希工具库引用的,buffer是被 SM4 库间接带的,stream则是那个哈希库的依赖链带出来的。三个模块,三个来源,其中只有buffer是我真正需要的。
心得:不要看到报错就一次性把
crypto、stream、buffer、path、os、fs全配上。先补报错的那一个,重新构建,看下一个报什么,再补。这样你至少知道每个补丁对应什么问题,将来出问题也知道从哪儿查。一次性全配的结果是包体积莫名其妙变大,而且你已经不知道是哪一行配置导致的。
3.2 第二步:webpack 配置补齐的完整写法
定位清楚之后,改配置。我用的是 webpack 5,配置写在webpack.config.js里,核心是两块:resolve.fallback和plugins里的ProvidePlugin。
const webpack = require('webpack') const path = require('path') module.exports = { resolve: { fallback: { crypto: require.resolve('crypto-browserify'), stream: require.resolve('stream-browserify'), buffer: require.resolve('buffer/'), // 明确告诉 webpack:这些模块我们确实不用,直接置空 fs: false, path: false, os: false, }, }, plugins: [ new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'], process: 'process/browser', }), ], }这里有几个细节值得单独说。buffer那行写的是require.resolve('buffer/'),注意结尾那个斜杠——buffer这个包在本体里也有一个同名文件,不加斜杠 webpack 可能会解析到错误的入口,这个坑我踩过,症状是Buffer is not defined或者类型判断异常。fs、path、os这几个置为false,意思是“如果真被引用到,就直接返回一个空对象”,而不是报错。这招在只做浏览器端打包时很管用,因为这些模块大概率只在某些库的服务端分支里被条件引用,浏览器端根本走不到。
ProvidePlugin里的Buffer注入也很关键。有些库不写import { Buffer } from 'buffer',直接用全局变量,webpack 不注入就会在运行时报Buffer is not defined。这个报错特别迷惑人,因为构建阶段一切正常,只有运行时才炸,而且是白屏级别的炸。
如果你项目里用了 Vite 或者 Rollup,等价的做法是用resolve.alias加插件,思路一致,这里不展开。如果你确实懒得一个个配,可以用node-polyfill-webpack-plugin:
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin') module.exports = { plugins: [new NodePolyfillPlugin()], }但我还是那句话,能用精细配置就用精细配置,插件方式适合“我就是想快速验证一下业务逻辑”的场景,长期维护的项目还是老老实实一个个补。
3.3 第三步:封装一个可复用的解密模块
配置通了之后,我写了一个独立的解密模块src/utils/decryptor.js,把所有编码转换、模式判断、错误处理都收在里面,业务代码只调一个函数。
const { sm4 } = require('sm-crypto') /** * base64 转 hex,sm-crypto 的默认输入是 hex */ function base64ToHex(base64) { const buf = Buffer.from(base64, 'base64') return buf.toString('hex') } /** * SM4 解密主函数 * @param {string} cipherText base64 编码的密文 * @param {string} keyHex 32 位 hex 字符串(16 字节) * @param {string} ivHex 32 位 hex 字符串(16 字节) * @returns {object} 解析后的业务对象 */ function decryptPayload(cipherText, keyHex, ivHex) { if (!cipherText) return null // 参数校验前置,避免把错误抛给库函数 if (!/^[0-9a-fA-F]{32}$/.test(keyHex)) { throw new Error('[decrypt] key 必须是 32 位 hex 字符串') } if (!/^[0-9a-fA-F]{32}$/.test(ivHex)) { throw new Error('[decrypt] iv 必须是 32 位 hex 字符串') } const hexCipher = base64ToHex(cipherText) let plain try { plain = sm4.decrypt(hexCipher, keyHex, { mode: 'cbc', iv: ivHex, output: 'string', }) } catch (e) { throw new Error(`[decrypt] 解密失败: ${e.message}`) } if (!plain) { throw new Error('[decrypt] 解密结果为空,请检查密钥、IV 或密文格式') } try { return JSON.parse(plain) } catch (e) { throw new Error(`[decrypt] 明文不是合法 JSON: ${plain.slice(0, 50)}`) } } module.exports = { decryptPayload, base64ToHex }这段代码里有三个设计决策,我解释一下为什么这么写。
第一,为什么要做参数校验前置。sm-crypto在 key 长度不对的时候,行为不一定是抛错,有时候是返回空字符串,有时候是返回一堆乱码。你拿着乱码去JSON.parse,报的是Unexpected token,错误信息指向的地方跟真实原因差了十万八千里。把校验放前面,错误信息直接告诉你“key 长度不对”,排查时间从半小时缩短到半分钟。
第二,为什么要捕获解密异常并重新包装。原始异常信息可能是Invalid array length这种毫无指向性的描述,重新包装后至少能让你在日志里一眼看出是解密环节出的问题。
第三,为什么明文解析失败时要把明文前 50 个字符打出来。这是我在实际排查中救过自己很多次的一招:解密成功但明文乱码,通常意味着 key 或 iv 错了,或者模式不对,但代码层面一切都是“成功”的。把那 50 个字符打在错误信息里,你一眼就能看出是乱码还是差了几个字符,判断起来快得多。
3.4 第四步:用标准测试向量先自证
写完之后我没有直接接业务接口,而是先拿标准测试向量自测。SM4 有一个公开的标准示例:密钥和明文都是0123456789abcdeffedcba9876543210,ECB 模式、单分组、无填充的情况下,密文是681edf34d206965e86b3e94f536e4246。
const { sm4 } = require('sm-crypto') const key = '0123456789abcdeffedcba9876543210' const plain = '0123456789abcdeffedcba9876543210' const cipher = sm4.encrypt(plain, key, { padding: 'none' }) console.log(cipher) // 期望输出:681edf34d206965e86b3e94f536e4246这个自测的价值在于:它把“库有没有问题”和“我的参数有没有问题”这两件事彻底分开了。如果这一步过不了,那说明库选错了或者调用方式错了,后面的联调压根不用开始;如果这一步过了,那后面联调出问题,原因一定在业务参数上,排查范围瞬间缩小一半。
注意padding: 'none'这个选项,标准向量是单分组无填充的,如果你用默认的 PKCS#7 填充,加密结果会多出一个分组,跟标准值对不上,别以为自己写错了。
4. 联调阶段:编码、padding、中文乱码
配置通了、模块封装好了、自测也过了,我以为半小时能收工,结果在联调上耗了整整两天。这一章把踩过的坑按类型整理出来,都是那种“看起来很简单但就是不对”的问题。
4.1 base64 与 hex 的错位
第一个坑是编码格式。sm-crypto的sm4.decrypt默认把输入当成 hex 字符串处理,但后端传过来的是 base64。我第一次调用的时候没做转换,直接把 base64 字符串丢进去,结果解出来是一堆乱码,还不报错。
为什么会这样?因为 base64 的字符集里包含+、/、=,这些字符在 hex 解析时会被当成非法字符跳过或者按某种规则截断,剩下的部分仍然是一个“长度合法”的字符串,库函数不会认为参数有问题,就照常往下算了,算出来的当然是无意义的结果。这种“静默错误”比直接抛异常难查十倍。
解决办法就是我在 3.3 里写的base64ToHex,先Buffer.from(base64, 'base64')转成字节数组,再toString('hex')。反过来,如果你的场景是加密,输出也需要从 hex 转 base64:
const hexCipher = sm4.encrypt(plain, key, { mode: 'cbc', iv }) const base64Cipher = Buffer.from(hexCipher, 'hex').toString('base64')注意:跟后端确认编码格式的时候,不要只问“是 base64 吗”,要问“密文的原始字节是怎么编码成字符串的”。有些后端会先做 base64 再做一次 URL 编码,有些会做十六进制大写,这些细节不问清楚,你就得靠猜。
4.2 padding 模式与密钥长度
第二个坑是填充。SM4 是分组密码,分组长度固定 16 字节,如果你的明文不是 16 的整数倍,就必须填充。前端这边sm-crypto默认用 PKCS#7 填充,解密时会自动去掉填充。但问题在于,后端用什么填充、用什么模式,得对上。
我当时遇到的情况是:后端用的是 PKCS#5,前端默认 PKCS#7。听起来不一样,实际上在 16 字节分组下两者是完全等价的(PKCS#5 名义上只定义 8 字节分组,但实现上就是 PKCS#7 的子集),所以这个不影响。真正出问题的是模式——后端用的 CBC,我一开始没传mode,默认走了 ECB,解出来的前 16 字节是对的,后面全是乱的。这个现象很有迷惑性,因为你能看到部分正确内容,会以为是编码问题而不是模式问题。
密钥长度也是常见坑。SM4 的密钥固定 128 位,也就是 16 字节、32 位 hex 字符。有些后端会把密钥做一次 MD5 或者 SHA 派生再使用,前端如果直接拿原始密钥去解,必然失败。这种情况你需要跟后端要派生规则,或者干脆让他给你一个已经派生好的 hex 密钥。我遇到过最离谱的一次是后端把密钥字符串做了 UTF-8 编码后取前 16 字节,而前端直接把这个字符串当 hex 解析——两边的“16 字节”根本不是同一批字节。
4.3 中文与特殊字符的处理
第三个坑是中文。解密出来的字符串如果JSON.parse报Unexpected token,先别怀疑 JSON 格式,八成是字符集问题。sm-crypto的output: 'string'是按 UTF-8 解码的,如果后端加密前把明文按 GBK 编码成字节,前端按 UTF-8 解出来就是乱码。
判断方法很简单,把output改成'array',拿到字节数组,自己用Buffer.from(arr).toString('utf8')转一次,如果还是乱码,基本可以确定后端用的不是 UTF-8。这时候要么让后端改成 UTF-8,要么前端引入一个 GBK 解码库(比如iconv-lite,注意它在浏览器端也需要补齐 buffer)。
还有一个更隐蔽的情况:解密出来的 JSON 本身是合法的,但某些字段的中文变成了????。这通常是后端在加密前做了一次String.getBytes()用了平台默认字符集,而不是显式指定 UTF-8。这种问题只能让后端改,前端怎么解都救不回来。
5. 常见问题速查与排查思路
这一章把我在这个项目以及之前几个类似项目里遇到过的问题整理成速查表,配合排查顺序,你遇到报错可以先扫一遍,大概率能直接命中。
5.1 构建期报错速查表
| 报错信息 | 根本原因 | 处理方式 |
|---|---|---|
| Can't resolve 'crypto' | webpack 5 不再自动注入 polyfill | resolve.fallback.crypto = require.resolve('crypto-browserify') |
| Can't resolve 'stream' | 同上,多由哈希类库间接引入 | stream-browserify,或若走不到该分支则置false |
| Can't resolve 'buffer' | 库内部使用 Buffer 但未显式导入 | buffer/加ProvidePlugin注入全局 |
| Buffer is not defined(运行时) | 构建通过但运行时缺全局变量 | ProvidePlugin({ Buffer: ['buffer','Buffer'] }) |
| process is not defined(运行时) | 代码里用了process.env | ProvidePlugin({ process: 'process/browser' }) |
| Module parse failed: Unexpected token | 引入的库用了 ESM 但没配 babel | 在module.rules里对该库做转译,或换 CJS 版本 |
排查顺序我建议这样走:先看报错路径在node_modules还是src,再看这个模块是被谁引用的(用 stats.json 或者--stats detailed),最后决定是补 polyfill 还是改代码。能改代码就不要补 polyfill,因为 polyfill 是纯增量成本,而改代码通常能让依赖树更干净。
5.2 运行期解密失败速查表
| 现象 | 可能原因 | 验证方法 |
|---|---|---|
| 解密结果为空字符串 | key 或 iv 长度不对 | 打日志确认是 32 位 hex |
| 解密结果部分正确部分乱码 | 模式不对(ECB/CBC 混用) | 确认mode和iv是否都传了 |
| 解密结果全是乱码 | 密文编码格式错(base64 当 hex) | 先做 base64 转 hex 再解 |
| JSON.parse 报错但内容看着正常 | 存在不可见字符或 BOM | 打印JSON.stringify(plain)看转义 |
| 中文变问号 | 后端编码非 UTF-8 | 让后端确认getBytes的字符集 |
| 偶发解密失败 | 密钥轮换导致的前后端不同步 | 检查密钥版本号字段 |
我特别想强调最后一行。如果后端做了密钥轮换,响应体里通常会带一个keyVersion之类的字段,前端需要根据这个字段选择对应的密钥。我见过一个项目,密钥轮换后前端没处理这个字段,导致轮换那一刻开始,大约 5% 的请求解密失败,而且因为失败率不高,监控也没报警,愣是拖了一周才被发现。
6. 安全边界与工程化收尾
功能跑通只是及格线,这个模块上线前还有几件事必须处理:密钥怎么放、包体积怎么控、异常怎么兜。这一章讲我实际做的处理,也包括一些“知道但做不到完美”的现实妥协。
6.1 前端密钥必然暴露,怎么降低风险
先把话说透:只要密钥被写进前端代码,它就不是秘密。无论是硬编码在 JS 里、放在.env里被DefinePlugin注入、还是运行时从接口拉取,用户打开开发者工具都能拿到。混淆只能提高一点点门槛,防不住真正想看的人。
但这不代表什么都不用做。我实际采取的措施有三条。第一,密钥分环境,测试环境和生产环境的密钥完全独立,即使测试环境泄露也不影响线上。第二,密钥带有效期,由后端在登录响应里下发一个短期有效的密钥和 iv,前端存内存不存 localStorage,页面刷新或超时后重新获取。这样即使有人抓到了密钥,窗口期也有限。第三,服务端做二次校验,解密出来的业务请求带上签名,服务端验证签名有效性,防止用户篡改明文后重放。
这三条里,第二条和第三条是真正有安全价值的,第一条是基础卫生。至于用 SM2 做密钥协商这种更完整的方案,我评估过,实现复杂度上升不少,而且需要后端配合改造,这个项目的时间窗口不允许,就先用短期密钥 + 签名校验这套折中方案顶上。
提示:如果你看到某个方案号称“前端加密绝对安全”,可以直接跳过。前端加密的目标是提高攻击成本、防止明文裸奔,不是做到不可破解,把这个定位摆正,方案设计就不会走偏。
6.2 样式之外的收尾:体积、SSR 与测试
包体积这块,补齐 polyfill 之后我的主包大概涨了 60KB(gzip 后约 20KB),主要来自crypto-browserify那条依赖链。优化手段有两个:一是把解密模块拆成独立的 chunk,用动态import()按需加载,首屏不加载它;二是在splitChunks里把crypto-browserify相关模块单独分组,避免它被重复打进多个 chunk。
optimization: { splitChunks: { cacheGroups: { cryptoPolyfill: { test: /[\\/]node_modules[\\/](crypto-browserify|randombytes|create-hash)[\\/]/, name: 'crypto-polyfill', chunks: 'all', priority: 10, }, }, }, }SSR 场景要特别注意。服务端跑的是 Node,Buffer、crypto这些是原生模块,不需要 polyfill;但如果你用同一份 webpack 配置打两端,polyfill 会被打进服务端产物,运行时可能跟原生模块冲突。正确的做法是用两份配置,服务端配置里把target设成node,resolve.fallback全部去掉,让 Node 自己去解析原生模块。
测试方面,我把 3.4 里的标准向量写成了单测用例,另外加了一条“真实响应体解密”的集成测试,用前面存的 fixture 文件。这两条测试在后续每次升级依赖时都跑一遍,帮我拦下过一次sm-crypto次版本升级导致的 padding 行为变更。加密相关的代码,测试覆盖率比业务代码更值得投入,因为它的错误是全局性的,一个参数错,整站数据都出不来。
6.3 我的几点实操心得
最后分享几条这个项目里沉淀下来的经验,都是那种文档里不会写、只有踩过才知道的东西。
第一条,先跑通最小闭环,再谈工程化。我一开始想一次性把 webpack 配置、密钥管理、错误上报全部做完,结果在配置上卡了两天,一行业务代码没写。后来调整策略,先用最简单的方式(硬编码密钥 + 单个文件)把一条数据解密出来,确认链路通了,再回头做配置优化和安全加固。这个顺序很重要,因为它让你始终有一个“已知可用”的基线。
第二条,错误信息要写给未来的自己看。解密失败时的错误信息,如果只是decrypt failed,三个月后你看到日志会一脸懵。把 key 长度、iv 长度、密文长度、模式、解密结果前几十个字符全部打进去,虽然日志会长一点,但排查时能省下大量时间。当然,密钥本身绝对不能打进日志,这个红线要守住。
第三条,别怕回退。中途我一度想把 webpack 升级到最新版本,顺便把所有 polyfill 换成插件方案,结果构建时间从 40 秒涨到 3 分钟,热更新也不工作了。我纠结了半天,最后还是回退到原来的精细配置。老项目的构建配置,能不动就不动,你的目标是加一个解密模块,不是重构整个构建体系。边界感这件事,在别人的仓库里尤其重要。
这套东西上线到现在运行了几个月,解密失败率稳定在万分之几,基本都是网络传输截断导致的,重试即可。真正让我觉得值的是,后来团队里另一个项目也要做类似的密文接口,我把decryptor.js和那份 webpack 配置片段直接拷过去,半小时就跑通了——这说明当初把它拆成独立模块、把每个参数校验写清楚,是对的。