ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

前端JS加密实战:从哈希、AES到防篡改签名方案

前端JS加密实战:从哈希、AES到防篡改签名方案 1. 先把需求说清楚前端加密到底在防谁最近遇到好几个朋友问我同一个问题为什么浏览器里用 JavaScript 写的加密后端一验就挂甚至有人直接在 network 面板里把密文拖出来换几个参数又发回去接口照样通。说实话这不是加密算法选错了而是很多人一开始就没想清楚JS 加密到底防的是谁又是怎么个防法。1.1 三种典型的加密诉求我习惯把前端的加密需求拆成三类。第一类是“防抓包”。比如登录接口用户输入密码后不能直接以明文飘在网络上。有人会说都上了 HTTPS 怎么还会被抓包说实话HTTPS 的确加密了传输层但你可以打开浏览器开发者工具在 Network 面板里照样能看到请求体里的明文密码。为什么因为数据在到达浏览器、离开浏览器的那一刻在 JS 内存里就是明文。HTTPS 保护的是链路不是你的应用层数据。所以很多团队依然要求前端做一层加密目的就是让抓包的人拿到手的东西不是一眼能看懂的原文。第二类是“防篡改”。比如一个下单接口价格、数量、优惠券这些字段如果直接放请求里攻击者用工具改了金额再提交服务器如果没有足够的校验就会出问题。前端要做的不是“不让改”而是“改了之后服务器能发现问题”这通常靠签名和校验。第三类是“防数据被拖库后泄露”。这里要坦白讲前端加密在这类场景里能做的事情非常有限。密码能不能安全存储取决于后端的哈希方案和盐的管理前端能做的主要是“不把密码以明文形式提交给服务器”以及用密码派生函数增加暴力破解成本。1.2 必须提前排除的误区我得先把话说得难听一点JavaScript 加密不能带来“绝对安全”。代码在浏览器里跑就意味着用户一定能拿到你的源码、一定能看到你的密钥。所以前端加密的定位不是“保险柜”而是“减速带”——让大多数普通用户、半吊子爬虫脚本和接口捡漏的人停下来让真正有耐心的逆向者也需要多花几个小时甚至几天。另一个常见的误区是只加密请求参数不检查响应数据。比如前端做了 AES 加密却忽略了登录成功后返回的 token 被明文存储在 cookie 里或者错误信息里把原始堆栈直接抛给用户。这些漏洞等于把前门锁好了却把窗户敞着。还有一点加密方案设计好后一定要和后端开发同事一起评审前后端各自用什么算法、什么模式、什么编码必须拉齐。我在公司见过最惨的一次线上事故就是前端用 crypto-js 的 AES-CBC 加密后端接过来却用 Java 默认的 AES/ECB/PKCS5Padding 去解最后接口全线报错排查了整整一个下午才发现是模式不一致。2. 常用的 JS 加密算法盘点哈希、对称、非对称与编码前端能用的加密算法主要藏在两个地方一个是 Web Crypto API这个是浏览器原生支持的不需要额外引库但 API 写起来比较啰嗦另一个是第三方库比如 crypto-js、jsencrypt、forge胜在接口简单、文档丰富。下面把常用的几类逐一讲清楚。2.1 哈希算法MD5、SHA-1、SHA-256 与 SHA-3哈希算法的作用是把任意长度的数据映射成固定长度的摘要而且理论上不可逆。前端最常用的场景是给参数生成指纹、做完整性校验、生成签名摘要。注意我在这里没有提“密码加密存储”因为密码存储的正确姿势是加盐哈希后面在实战章节会细说。先看一段用 Web Crypto API 计算 SHA-256 的代码async function sha256Digest(message) { const data new TextEncoder().encode(message); const digest await crypto.subtle.digest(SHA-256, data); return Array.from(new Uint8Array(digest)) .map(b b.toString(16).padStart(2, 0)) .join(); }这段代码返回的就是一个 64 位的十六进制字符串。SHA-256 输出的长度固定是 32 字节也就是 256 位所以叫 SHA-256。如果你看到有人用 MD5输出的就是 32 位十六进制字符串安全性上已经不太建议用于对抗性场景了因为 2004 年就有人找到了碰撞后来是越来越容易。现在很多老系统还在用 MD5多半是历史包袱太重换不动了。SHA-1 同样不建议继续用于安全场景它的碰撞攻击成本在 2017 年之后已经低到可以被实际执行。但如果只是做数据完整性校验不是对抗恶意攻击者SHA-1 和 SHA-256 的差别其实不大。我在项目里延续下来的习惯是新代码一律 SHA-256 起步。SHA-3 是新一代标准前端支持度这些年好了一些Web Crypto API 里部分浏览器已经开始支持但第三方库 crypto-js 本身并不直接支持 SHA-3。如果不是确实有合规要求或者算法迁移诉求我一般不推荐前端上 SHA-3收益不明显反而增加了兼容性风险。2.2 对称加密AES 加密与三种常见模式对称加密的核心是加解密用同一个密钥。前端里最常用的对称加密算法是 AES密钥长度一般取 128、192、256 位。为了保证和主流语言互通我建议直接选 AES-128 或 AES-256不要选太冷门的变体。AES 有几种模式实战中最常看到的是 ECB、CBC 和 GCM。它们的区别用生活里的例子比较好理解ECB 是把数据切成一块一块的每一块单独加密块与块之间没有任何联系性能高但相同明文会得到相同密文模式很容易被识别和推测所以我强烈不建议用 ECB 保护业务数据。CBC 是每一块加密前先和上一块的密文做异或运算所以同样的明文块在不同位置加密后结果不一样安全性提升了不少但需要初始化向量 IV而且 IV 必须随机生成、不能复用。GCM 是在 CBC 思路基础上又加了认证标签密文只要被篡改过解密时校验就会失败这是目前我实际项目中比较倾向的模式。看一个用 crypto-js 实现 AES-GCM 的示例const CryptoJS require(crypto-js); // 加密 function encryptGCM(plainText, key) { const iv CryptoJS.lib.WordArray.random(16); // 随机16字节IV const encrypted CryptoJS.AES.encrypt(plainText, key, { iv: iv, mode: CryptoJS.mode.GCM, padding: CryptoJS.pad.Pkcs7 }); // 密文、IV、认证标签常拼接在一起传输 return { ciphertext: encrypted.ciphertext.toString(CryptoJS.enc.Base64), iv: iv.toString(CryptoJS.enc.Hex) }; }这里有一个关键细节接收方必须同时拿到 IV 才能解密。IV 不是密钥所以它不一定需要加密但一定要保证传输过程中的完整性。GCM 模式自带认证所以它可以防篡改如果用的是 CBC那还需要额外再算一遍 HMAC 才能达到类似的效果。2.3 非对称加密RSA 与密钥分发非对称加密有两个密钥公钥负责加密私钥负责解密。前端放公钥后端留私钥。这样做的好处是即使攻击者把前端代码和公钥全部扒走了他也没办法解密别人发过来的数据更不能伪造一段能被私钥解开的密文。前端的 RSA 常用库是 jsencrypt用法很直接import JSEncrypt from jsencrypt; const publicKey -----BEGIN PUBLIC KEY----- 这里填后端下发的公钥 -----END PUBLIC KEY-----; const encryptor new JSEncrypt(); encryptor.setPublicKey(publicKey); const encrypted encryptor.encrypt(要加密的内容);RSA 有个现实问题性能比 AES 慢得多而且加密内容长度受限。以 2048 位密钥为例最多能加密的明文长度大约是 2048/8 - 11 245 字节。也就是说一篇几百字的表单直接塞进去 RSA 可能就超了。所以实际项目里更常用的方案是“混合加密”用 AES 的随机密钥加密业务数据再用 RSA 公钥加密这个 AES 密钥后端收到后用私钥解开 AES 密钥再解业务数据。除了 RSA国内很多金融和政务类项目要求用国密算法比如 SM2、SM3、SM4。SM2 是椭圆曲线非对称算法SM3 是哈希算法SM4 是对称加密。前端要用这些算法通常需要引入专门的国密库例如 sm-crypto。这类需求多出现在合规场景普通互联网业务不太常见。2.4 编码类“假加密”Base64、Hex 与 URL 编码经常有人在群里贴一段 Base64 说“这个我加密了怎么还是被破解”。其实 Base64 不是加密它只是编码目的是把二进制数据转成文本方便在网络中传输。Base64 解码是零门槛的任何人都能一眼看出来。Hex 同理就是 16 进制的字符表示。这些编码手段在加密链路里的角色是“传输格式”常用来把密文从字节流转成字符串方便放在 JSON 字段里。千万不要把 Base64 本身当作安全措施。你拿 Base64 去“加密”一个密码和直接把密码明文放在请求里没有本质区别只是多了一道工序。3. 实战给登录接口写一套靠谱的加密签名方案讲完理论接下来给一套可以落地的登录接口加密方案。这套方案不会过度设计适合大多数中小型前后端项目同时能挡住绝大多数随手抓包篡改的请求。3.1 整体方案设计先明确目标登录接口要防的是“密码明文出现在抓包工具里”“请求参数被篡改后重放”。基于这个目标我采用的方案组合是用 RSA 公钥加密一个随机生成的 AES 密钥用这个 AES 密钥通过 AES-GCM 模式加密密码明文在请求体中追加时间戳后端验证时间窗口防止重放请求参数的整体摘要用 SHA-256 生成并放到签名字段中对密码再做一次 PBKDF2 派生防止存储端拿到密码后直接撞库。为什么不直接拿 RSA 加密密码原因前面说了RSA 有长度限制而密码经过加盐后可能超过 245 字节另外 RSA 性能慢不适合高频调用。为什么不直接用固定 AES 密钥因为固定密钥一旦写在 JS 里谁都可以翻出来用。所以让每个会话生成一次临时 AES 密钥是相对稳妥的做法。3.2 核心代码PBKDF2 密码派生与 AES-GCM 加密先看前端这段async function deriveKey(password, salt) { const encoder new TextEncoder(); const baseKey await crypto.subtle.importKey( raw, encoder.encode(password), PBKDF2, false, [deriveKey] ); const derivedKey await crypto.subtle.deriveKey( { name: PBKDF2, salt: encoder.encode(salt), iterations: 10000, hash: SHA-256 }, baseKey, { name: AES-GCM, length: 256 }, true, [encrypt] ); return derivedKey; } async function encryptLoginPayload(password, salt, sessionPublicKey) { // 1. 密码派生加盐后生成中间密钥 const derivedKey await deriveKey(password, salt); // 2. 用派生密钥的原始字节作为最终密码做AES-GCM加密 const rawDerivedKey await crypto.subtle.exportKey(raw, derivedKey); const aesKey await crypto.subtle.importKey( raw, rawDerivedKey, AES-GCM, false, [encrypt] ); const iv crypto.getRandomValues(new Uint8Array(12)); const ciphertext await crypto.subtle.encrypt( { name: AES-GCM, iv: iv }, aesKey, new TextEncoder().encode(password) ); // 3. 用RSA公钥加密这个派生密钥 const encryptedKey await encryptWithRSA(sessionPublicKey, rawDerivedKey); // 4. 返回密文、IV、会话公钥密文、时间戳 return { encryptedKey: arrayBufferToBase64(encryptedKey), iv: arrayBufferToHex(iv), ciphertext: arrayBufferToBase64(ciphertext), timestamp: Date.now() }; }这里的关键点是salt 从哪里来我建议由服务端在初始化阶段下发每次登录请求前先拉一次盐。这样即使同一个用户、同一个密码因为盐不同最终生成的密文也不一样。迭代次数 10000 够不够对登录这种低频请求可以提高到 100000 甚至更高代价只是用户等待几十毫秒。PBKDF2 存在的意义就是“拖慢暴力破解”迭代次数越高越好但要兼顾性能。IV 长度为什么是 12 字节GCM 模式推荐 IV 长度为 12 字节这是标准建议值。如果取 16 字节虽然也能工作但有些实现会有兼容性问题。3.3 后端校验逻辑时间戳、签名与解密后端这块我只给 Java 风格的伪代码方便说明思路1. 接收请求体取出 timestamp 2. 判断 timestamp 是否在当前时间前后 5 分钟窗口内超出则拒绝 3. 用私钥解密 encryptedKey得到 AES 密钥的原始字节 4. 用该 AES 密钥、IV 对 ciphertext 进行 AES-GCM 解密 5. 解密失败说明数据被篡改过直接返回 400 6. 校验通过后再走正常的账号密码校验逻辑。这里要特别提一个细节不要把密文、IV、时间戳放在同一个被删改的请求里还指望能防篡改。你需要把关键字段合并成一个字符串用服务端和前端共享的签名密钥生成 HMAC-SHA256然后再把这个摘要放到请求头或请求体里。后端接到请求后先重算一遍摘要对不上就直接丢弃。时间戳防重放的原理是即使攻击者抓到了完整请求只要过了时间窗口重放就会失效。3.4 参数计算与常见选型说明我在项目里一般这样配置参数读者可以直接参考参数推荐值说明AES 密钥长度256 位兼容性好安全性足够IV 长度12 字节GCM 模式推荐值RSA 密钥长度2048 位可以加密约 245 字节满足 AES 密钥传输PBKDF2 迭代次数10 万以上根据用户端性能实测调整时间戳窗口5 分钟兼顾可用性和安全哈希摘要SHA-256通用且安全如果你做的系统已经做了 HTTPS并且前后端都是自家控制的那完全可以简化成“HTTPS 密码加盐哈希”的方案。加一层 AES/RSA 是锦上添花不是必需品。真正应该加大投入的地方是后端的频率限制、验证码、设备风控和账号锁定策略。4. JS 逆向与加密对抗你能防住谁写前端加密就必须面对一个现实我们的代码是发给用户的用户手里有完整的程序。学过 JS 逆向的人都知道浏览器里的一切函数、变量、密钥最终都能被翻出来。所以这一节我想聊聊对抗视角帮大家理解边界在哪里。4.1 从逆向工程师角度观察你的加密我做过一段时间接口逆向拿到一个加密请求思路一般是这样的先全局搜索关键字比如encrypt、AES、password、sign定位加密函数再在函数入口打断点看入参和出参把加密前的明文和加密后的密文对比接着往调用栈上一层一层翻找出密钥、IV、salt 是在哪个环节生成的最后把核心函数抠出来用 Node.js 单独跑一遍整个加密流程就原样复现了。这个过程对纯 JS 在浏览器里运行的加密尤其轻松因为代码是解释执行的没有编译期保护。历史上真正增加逆向难度的操作是混淆比如把变量名改成_0xk3n9、把流程控制打散、加入虚假分支、字符串数组化。混淆级别的提升对应的是逆向者阅读成本的大幅上升。4.2 正规加固手段混淆、反调试与动态密钥有人会问既然前端代码都能被看到那是不是不用加密了不是。加密的意义是提升攻击成本只要成本高过了收益大多数攻击者就会放弃。具体来说有三个方向可以叠加第一是混淆。开源方案里比较成熟的是javascript-obfuscator它支持控制流平坦化、字符串隐藏、死代码注入等。我实测过同样的代码混淆后体积膨胀大约 3 到 5 倍执行效率下降约 30%但对阅读者来说基本是灾难。第二是反调试。常见的检测手段有周期性检查debugger关键字是否被跳过、检测浏览器devtools是否打开、检测setInterval是否有异常延迟。但这类方案对正常用户也有影响有些浏览器插件、自动化测试工具会被误伤。我在实际项目里一般把反调试开到最低档只在关键函数入口做一次轻量检测。第三是动态密钥。这是比混淆更有效的方案让密钥每次从服务端下发甚至每次请求都用不同的密钥。前面实战方案里提到的“服务端下发盐 会话 RSA 密钥”就是动态密钥的思想。就算攻击者把整段 JS 代码复制走了他没有服务器的协助也拿不到当前会话的密钥解密就很难进行。提示如果你们的业务场景是“防爬虫”而不是“防篡改”我建议优先考虑后端风控比如接口频率限制、设备指纹、行为验证码、动态令牌。不要把所有期望押在前端加密上那样既影响性能也挡不住决心够大的人。5. 常见问题与踩坑记录下面这些坑绝大多数是我自己在项目里或者帮朋友排查问题的时候真实遇到的。整理成一份速查表希望能帮看到这篇文章的朋友绕开。5.1 加解密结果总是对不上最典型的症状是前端用 crypto-js 加密提交后端 Java 解密失败或者解密出来是一堆乱码。常见原因有这几个。第一个是编码不一致。前端用CryptoJS.enc.Utf8.parse()生成的字节序列和后端getBytes(UTF-8)如果对不上比如后端用了 ISO-8859-1结果必然不一样。解决办法是统一用 UTF-8并在后端解密后打印一段 Base64 编码的明文和前端的输入做比对。第二个是填充模式不一致。前端默认用Pkcs7Java 里对应PKCS5Padding虽然算法实现上可以兼容但如果你前端写了ZeroPadding而后端用了PKCS5Padding那密文长度不对解密立刻就挂。代码里最好显式声明填充模式不要吃默认值。第三个是IV 处理不对。使用 CBC/GCM 模式时IV 是一个必须携带的参数。有些前端代码把iv.toString()转成了字符串后端取到之后没有做 Hex 解码直接当成字符串用了解密自然对不上。IV 就是一组原始字节传输时要转成 Base64 或 Hex接收时要转回字节这个步骤别省略。5.2 前端密钥被扒走后怎么办如果发现请求里的密钥已经被人提取并公开了赶紧做这几步先让后端发布新的密钥对或新的盐规则再加一层服务端动态签名的逻辑让前端不再持有真正用于校验的密钥同时考虑引入一次性随机数把“密钥泄露”的影响范围控制在短期窗口内。坦白讲如果业务对安全的要求已经到“完全不能让人破解”的级别就不该把关键逻辑放在前端了。更稳妥的做法是由服务器端渲染页面、让敏感操作走服务端接口代理、引入硬件级认证等。前端加密只能作为纵深防御中的一环不能作为唯一防线。5.3 为什么浏览器控制台里能直接看到加密函数经常有同事看到我在控制台里输入Object.keys(window)就会问这样不是把所有代码都暴露了吗确实会。JS 这门语言在设计上就决定了“客户端代码对用户可见”严格来说不是漏洞而是取舍。这种特性带来的好处是零安装、跨平台、动态加载但代价就是无法真正隐藏代码逻辑。所以我觉得大家不用纠结“能不能实现一个别人看不懂的前端加密”而要问自己我要防的是什么级别的攻击者如果是防随手抓包的人随便一个 AES-CBC 加上 Base64 就能挡住大部分如果是防专业的逆向工程师那就需要后端配合做动态密钥和风控而不是追求“某个函数不被看见”。5.4 大小写、散列值与“JS 散度”这类搜索词的小提示有一些开发者在搜索框里输入“js 散度”或者“js 忽略大小写”其实是把“算法名称”和“编程语言”混在一起来搜了。借这个地方简单说一下JS 里做加密和做字符串处理经常是一起出现的。比如有些签名算法要求对参数按 ASCII 码排序排序时是否忽略大小写就会直接影响最终签名结果。如果后端是 Java默认字符串排序是区分大小写的而前端 JS 的sort()也是按 UTF-16 码元比较两者恰好一致。但如果你手动做了toLowerCase()那就可能导致前后端排序结果不一致签名永远对不上。这种问题排查起来非常隐蔽建议所有参与签名计算的字段在拼接前先明确大小写策略并且写进接口文档。6. 关于前端加密我最后想说的几句话做了这些年前端也和很多做后端、做安全的同事聊过我个人最深的体会是不要迷信某个算法更不要迷信“前端加密”。算法本身是可靠的工具但它的安全边界是清晰的。真正让系统变脆弱的往往是对边界判断失误——以为加了密就高枕无忧结果密钥写在 JS 里被人一眼看到或者以为加密能挡住逆向结果日志里的明文数据自行暴露了全部过程。如果你现在正要给项目设计加密方案我的建议是先画一张数据流向图标出每个环节谁是可信的、谁是不可信的再决定在哪个环节用哪种加密。HTTPS 管传输AES 管数据量大的加密RSA 管密钥交换SHA-256 管完整性校验PBKDF2 管密码存储。每个工具用在它该用的地方。最后再分享一个小技巧写加密相关代码时把前端加解密、后端加解密放在同一个测试用例里验证而不是两边各写各的。我曾经吃过亏前端自测通过后端自测也通过结果联调的时候因为一个字段的编码问题浪费了两天。后来我都用一个 Node.js 脚本同时调用前端加密库和一个简单的后端解密实现做交叉验证跑通了再上线省了很多没必要的扯皮。加密这件事慢就是快把基础细节打牢后面才能真的安稳。我在实际项目中踩过的另一个值得说的坑是千万不要把 IV 和密钥放在同一个 JSON 里传给前端如果一定要传也要保证 IV 参与签名。有一次同事把 IV 直接放在 public config 里攻击者只要替换 IV再重新算一遍签名整个登录请求就可能被改造成恶意数据。签名算法里所有参与方都必须覆盖关键字段少一个都是给攻击者留门。前端加密不是万能药但也不是完全没用。想清楚边界、选对算法、做好联调它就真的能帮你挡住很多不该有的麻烦。希望这篇实践总结能给你一些参考也欢迎有不同见解的朋友在评论区交流各自踩过的坑。
返回列表