我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

前端加密实战指南:用Crypto-js实现AES、SHA-256与HMAC接口签名

前端加密实战指南:用Crypto-js实现AES、SHA-256与HMAC接口签名 前端加密这事很多人觉得是个“鸡肋” —— 反正密钥在前端代码里放着怎么着都能被抠出来加密还有什么意义这话有一定道理但真实项目里前端加密不是为了防“不坏好意的攻击者”而是为了解决一些非常具体的工程问题比如防止明文密码直接被记录在网络链路里、防止数据被第三方接口抓取后直接复用、满足等保或内部安全规范里的“敏感数据不落地明文”要求。我在做某几个后台系统时后端同事几乎都会提一句“密码能不能别明文传过来至少做个哈希或者加密。” 这种情况下与其跟后端扯皮不如直接上手一个靠谱的库 —— Crypto-js。Crypto-js 是目前前端用得最多的加密工具库之一支持 AES、DES、TripleDES、Rabbit、RC4、MD5、SHA-1、SHA-256、HMAC 等一堆算法API 设计也比较老派但胜在稳定、零依赖、体积可控、文档一大堆。本文不打算把文档复述一遍而是从实战角度拆解什么时候用对称加密、什么时候用哈希、什么时候用 HMAC以及我在实际项目里是怎么用 Crypto-js 落地的。1. 为什么要做前端加密真实的业务场景和需求边界先说个扎心的事实前端的加密防的不是真正的高手防的是“顺手牵羊 ”和“明文癖”。1.1 后端同事的诉求和合规要求我遇到最多的情况是这样的系统要过等保或者内部安全审计审计里面有一条要求“敏感数据在网络传输过程中不得以明文形式出现”。虽然全站上了 HTTPS 之后网络层已经有一层加密但审计的人不这么看他们看的是抓包结果—— 用浏览器 F12 或者抓包工具直接能看到密码框里输入的内容拼在请求体里这就不行。怎么办最合理的办法是前端做一层“预处理”密码字段先用密钥做一次加密或哈希再放到请求体重。还有一类场景是防“重放”。举个具体的例子某个内部系统的登录接口后端同学排查日志时发现有人用抓包工具截获了登录请求然后不断重放虽然拿不到密码明文但请求一直能成功。后来我用前端给时间戳和随机数做签名服务端校验签名和时效这个问题才算解决。1.2 前端加密不能解决什么这个边界必须讲清楚不然你会在错误的路上死磕。前端加密不能解决“密钥泄露”的问题 —— 因为密钥在代码里任何拿到你 JS 文件的人都能抠出来它也不能替代 HTTPS —— 因为加密算法替换不了传输层的证书验证和防中间人它更不能替代后端的密码哈希存储 —— 后端拿到你加密后的密码一样要再做一次加盐哈希再入库。那前端加密到底解决了什么我觉得有三点让敏感数据在“离开浏览器之前”就已经不是明文减少被抓包直接看到内容的可能性给请求加上签名和时效拦截掉大部分自动化重放脚本满足安全审计的纸面要求减少和合规团队、后端同事的沟通成本。把期望值放对位置你才能真正用好这个库而不是整天焦虑“密钥是不是会被偷”。1.3 Crypto-js 的技术选型背景前端加密库其实有别的选择比如 Web Crypto API 是浏览器原生支持的性能更好也更现代但它有几个问题IE 不支持、调用方式比较底层需要自己处理 buffer 和 TypedArray、在部分旧内核的 WebView 里会有兼容性坑。Crypto-js 则是一个纯 JS 实现任何能跑 JS 的地方都能用Node 环境也能调用API 封装得比较成熟尤其适合存量项目快速接入。所以如果你是在维护一个老后台系统或者需要兼容各种国产浏览器套壳环境Crypto-js 往往比 Web Crypto API 更省心。2. Crypto-js 基础使用与常见误区这一节先把工具本身的问题说透包括引入方式、编码细节、中文乱码问题这些是最容易开头就踩坑的地方。2.1 安装与引入的三种方式Crypto-js 的引入方式可以根据你的项目环境来选# 方式一npm 安装适合 Webpack / Vite 项目 npm install crypto-js// 在 ES Module 项目中这样引用 import CryptoJS from crypto-js; // 或者按需引入这样打包体积更小 import AES from crypto-js/aes; import SHA256 from crypto-js/sha256;!-- 方式二CDN 引入适合传统网页 -- script srchttps://cdn.jsdelivr.net/npm/crypto-js4.2.0/crypto-js.js/script !-- 引入之后全局会有 CryptoJS 对象直接用即可 --// 方式三Node.js CommonJS 环境 const CryptoJS require(crypto-js);实际项目里我优先建议走 npm 按需引入 —— 别一股脑把整个库打进去。crypto-js 全量文件在 100KB 级别压缩后好了不少但按需引入还能再省一点。体积这件事在后台系统里无所谓但如果你在做一个 H5 活动页或者小程序差 30KB 都可能影响加载评分。2.2 字符串、WordArray 与编码Crypto-js 处理数据时不是直接用字符串算的而是先把字符串转成它内部定义的 WordArray 结构。这个 WordArray 你可以通俗理解成一个“字节数组 位长度”的组合体。所有的加密、摘要、签名算法都是基于 WordArray 进行计算的。这就引出了第一个常见的坑编码不一致导致加密结果看不懂。比如你用 AES 加密一段中文如果你的代码忽略了 Utf8 编码可能得到一串乱码或者解密出来是乱的。Crypto-js 默认提供了一些编码转换器最常用的是// 字符串 - WordArray CryptoJS.enc.Utf8.parse(待加密的中文字符串); // WordArray - 字符串 cryptoJsLib.enc.Utf8.stringify(wordArray);这里有个细节UTF-8 是变长编码汉字占 3 字节如果你用 Latin1ISO-8859-1解析那中文字符就会直接变成乱码所以涉及中文必须用 Utf8。2.3 顺带来一个浏览器控制台就能玩的小例子打开浏览器 F12 的控制台把 CDN 引入的 crypto-js.js 拖进去然后试着跑一下const msg 你好前端加密; // 加密 const encrypted CryptoJS.AES.encrypt(msg, 123456).toString(); console.log(encrypted); // 一串 Base64 字符串 // 解密 const decrypted CryptoJS.AES.decrypt(encrypted, 123456).toString(CryptoJS.enc.Utf8); console.log(decrypted); // 你好前端加密你会发现这个例子看起来很简单对吧但注意上面这个写法用的是 passphrase口令Crypto-js 内部会自动做一次 MD5 或者其他处理生成真正的 key 和 IV —— 这种用法叫做“基于口令的加密”它不需要你手动管理 IV但每次加密结果会带一个随机盐。后面说到 AES 模式的时候我会再展开。3. 核心实战之一数据脱敏与对称加密解密对称加密是前端用得最多的一类AES 尤其常见。这一节把 AES 的几种模式、密钥格式和真实项目中的用法说清楚。3.1 AES 模式选择CBC 还是 ECB还是 CTRCrypto-js 提供的 AES 加密在CryptoJS.AES.encrypt函数内部可以根据你传入的参数自动选择模式。常用的就三种模式特点前端实际使用建议ECB最简单不需要 IV同样的明文块加密结果相同简单省事但安全性弱容易被分析出规律CBC需要 IV每个密文块与前一个块关联结果随机性更好最推荐适合绝大多数场景CTR / CFB流式模式适合长度不固定的数据用得少但如果你需要加密大文本可以考虑 CTR实际项目里我基本只用 CBC。ECB 虽然少传一个 IV省了点代码但密文模式太固定审计时也容易被挑刺。3.2 用密钥Key和 IV 做 AES 加密的标准姿势真实项目中不会像我刚才例子那样直接用口令字符串。正规做法是约定一个 16 字节或 32 字节的密钥以及一个至少 16 字节的 IV。前端和后端要共享受同一个密钥和 IV或者按照协议动态协商。import CryptoJS from crypto-js; const SECRET_KEY CryptoJS.enc.Utf8.parse(a1b2c3d4e5f6g7h8); // 16字节 const SECRET_IV CryptoJS.enc.Utf8.parse(1234567890123456); // 16字节 export function encryptByAES(plainText) { const encrypted CryptoJS.AES.encrypt( CryptoJS.enc.Utf8.parse(plainText), SECRET_KEY, { iv: SECRET_IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return encrypted.toString(); } export function decryptByAES(cipherText) { const decrypted CryptoJS.AES.decrypt(cipherText, SECRET_KEY, { iv: SECRET_IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return decrypted.toString(CryptoJS.enc.Utf8); }注意这里几个细节密钥长度决定 AES 是 128 位、192 位还是 256 位。上面密钥是 16 字节对应 AES-128IV 是 16 字节CBC 模式下 IV 长度必须等于块大小16 字节Pkcs7 是最常见的填充方式后端用 Java 的 AES/CBC/PKCS5Padding 也能对上因为 PKCS5 和 PKCS7 在 AES 场景下基本等价。3.3 加密字符串之后的传参与 Base64 处理Crypto-js 的encrypt方法返回一个 CipherParams 对象toString()默认输出 Base64 格式。如果你直接把这个 Base64 放进 URL 参数里会碰到/等符号被 URL 转义的问题所以通常需要做一次 URL 编码const base64 encryptByAES(待加密内容); const urlSafe encodeURIComponent(base64); // 或者自定义替换字符 // base64.replace(/\/g, -).replace(/\//g, _).replace(/$/, )我建议在前后端约定里直接明确传输格式为 Base64 URL Safe后端解码时先反转义。这个坑很小但往往会在联调时浪费半天时间。3.4 过期时间戳防重放对称加密的经典应用场景这里分享一个实际的场景。我在某个开放接口项目里需要把用户的手机号传给第三方服务商但又不想让手机号在请求体中明文出现服务商要求的协议是前端把手机号 AES 加密后传过去服务商解密后使用。如果只是单纯加密手机号攻击者仍然可以把整个密文截获后原样重放。于是我在加密前做了一层“包装”function buildUserPayload(phone) { const timestamp Date.now(); const random Math.floor(Math.random() * 1000000); // 实际构造一个对象再整体加密 const obj { phone: phone, ts: timestamp, nonce: random }; return encryptByAES(JSON.stringify(obj)); }服务端解密后不仅拿到手机号还要检查ts是否在有效时间窗口比如 5 分钟内nonce是否已经被使用过。这样就算密文被截获攻击者在时间窗口外重放就会失败时间窗口内重放也会因为 nonce 校验被拦下。前端这部分代码很简单但解决了一个真实存在漏洞。4. 核心实战之二MD5 / SHA-256 哈希与防篡改签名哈希算法是另一个大头尤其在参数签名、文件校验的场景里。4.1 哈希和加密的本质区别先做个区分不然很多人会把哈希和加密搞混加密是可逆的密文可以解密回明文哈希是不可逆的摘要无法还原原文。SHA-256 得到的 64 位十六进制字符串就是为了让人“算得出、解不开”。前端的哈希场景最常见的三种密码前端先做一次 SHA-256然后传给后端 —— 这样即使网络层被剥开看到的也不是明文密码生成请求签名把请求参数按字典排序拼接上密钥做一个 SHA-256 摘要放到 header 里后端用同样规则算一遍一样就认为是合法请求文件校验上传前用 SHA-256 算文件摘要后端下载后校验确保文件未被篡改。4.2 密码哈希到底要不要做加盐前端给密码做哈希时我强烈建议加上一个固定盐或者采用“用户名 密码 固定盐”的组合。原因很简单如果所有人密码都是简单密码那么相同的密码会得到相同的 SHA-256 值攻击者一看摘要就能猜出密码规律。加盐的本质是让相同密码的哈希结果变得完全不同但这里要有心理预期前端加盐只是混淆后端的加盐哈希才是正经的安全防线。4.3 请求签名防篡改的完整写法来一个真实可跑的签名逻辑这是我常用的写法import CryptoJS from crypto-js; function signRequest(params, secretKey) { // 1. 把参数按 key 排序 const keys Object.keys(params).sort(); // 2. 拼接成 keyvaluekeyvalue 形式 const query keys.map(key ${key}${params[key]}).join(); // 3. 加上时间戳和盐 const timestamp Date.now(); const raw ${query}timestamp${timestamp}secret${secretKey}; // 4. 计算 SHA-256 签名 const sign CryptoJS.SHA256(raw).toString(); return { timestamp, sign }; }用的时候const params { userId: 10001, action: queryScore, page: 1 }; const { timestamp, sign } signRequest(params, my-secret-key); // 发起请求时把 sign 和 timestamp 加到 header 里 fetch(/api/data, { method: POST, headers: { Content-Type: application/json, X-Timestamp: timestamp, X-Sign: sign }, body: JSON.stringify(params) });后端要做的事情就是拿到 body 参数重复相同规则拼串计算 SHA-256比对签名同时校验 timestamp 与当前时间差是否超过阈值。这个方法能挡掉 80% 的“不改参数随便重发”类问题。注意签名密钥是放在前端代码里的所以这个方案防的是普通用户和自动化脚本防不了拿到你 JS 源码认真分析的人。所有签名的安全级别都要按照这个预期来评估。4.4 用 SHA-256 做文件完整性校验还有一个非常实用的小场景上传大文件时提前计算文件 SHA-256后端做一致性校验。用 FileReader 读取文件后兼容性最好的做法是分片读取再计算。Crypto-js 本身支持 Algo 的增量更新这在处理大文件时尤其重要const fileInput document.getElementById(fileInput); function calculateFileSHA256(file) { return new Promise((resolve, reject) { const reader new FileReader(); const sha256 CryptoJS.algo.SHA256.create(); // 分片读取避免大文件一次读入内存 const chunkSize 1024 * 1024; let offset 0; reader.onload function(e) { const wordArray CryptoJS.lib.WordArray.create(e.target.result); sha256.update(wordArray); offset chunkSize; if (offset file.size) { readNext(); } else { resolve(sha256.finalize().toString()); } }; reader.onerror reject; function readNext() { const slice file.slice(offset, offset chunkSize); reader.readAsArrayBuffer(slice); } readNext(); }); }注意上面这个写法用的是CryptoJS.algo.SHA256.create()的增量接口而不是直接CryptoJS.SHA256(file)因为后者没法直接接收 ArrayBuffer而且一次读入大文件容易把内存撑爆。这个细节是我在实际做“前端上传文件 后端校验”时踩过坑后总结出来的。5. 核心实战之三HMAC 消息认证码与接口鉴权哈希和加密解决了“看不懂”和“防篡改”但有时候还需要解决“这个请求确实是来自合法来源”的问题。HMACHash-based Message Authentication Code就是专门干这个的。5.1 HMAC 和普通哈希的区别以及为什么更适合接口鉴权普通的 SHA-256 没有密钥因子任何人知道参数就能算出同样的摘要所以它只能用来校验“内容有没有变”不能用来证明“这是谁算的”。HMAC 则是在哈希过程中混入了密钥没有这个密钥就算不出同样的摘要。用生活化的话说普通哈希像指纹谁都能采集HMAC 像带印章的指纹印章是从特定机构领的造不了假。Crypto-js 提供 HMAC 的能力非常简单const hmacDigest CryptoJS.HmacSHA256(message, secretKey).toString();5.2 基于 HMAC 的接口鉴权尝试带时间窗口我在某个内部管理系统的前端里给所有 AJAX 请求统一加了一个拦截器每次请求自动带上 HMAC 签名import CryptoJS from crypto-js; import axios from axios; const API_SECRET internal-api-secret; axios.interceptors.request.use((config) { const timestamp Date.now(); const method (config.method || get).toUpperCase(); const url config.url || ; const body config.data ? JSON.stringify(config.data) : ; // 把请求的关键信息拼起来做 HMAC const message ${method}${url}${timestamp}${body}; const sign CryptoJS.HmacSHA256(message, API_SECRET).toString(); config.headers[X-Timestamp] timestamp; config.headers[X-Sign] sign; return config; });后端统一验签后能确认这个请求是来自“持有密钥的前端页面”而且请求体没有被篡改过因为 body 也参与了签名。这个方案在前后端分离项目里很好用通用于任何版本的接口。5.3 一个在实践中要注意的坑参与签名的字段必须和后端完全一致我记得有一次联调前端 HMAC 签名一直通不过排查了半天最后发现是JSON.stringify产生的字段顺序和对象拼接方式与后端不完全一致 —— 前端对象里字段顺序是 a、b、c后端用 JSON 序列化时顺序却是 a、c、b于是签名永远对不上。所以在前端做 HMAC 或 SHA 签名时建议直接手动按固定顺序拼字符串而不是依赖 JSON.stringify 的对象字段顺序// 推荐手动固定顺序 const message ${params.userId}|${params.action}|${params.page}|${timestamp}; // 而不是 const message JSON.stringify(params); // 顺序不可控容易出现偏差这个原则对任何签名方案都适用宁可自己写几个字符串拼接也别让序列化工具的顺序给你挖坑。6. 完整项目实践一份可直接参考的 EncryptService前面拆了一堆零散的知识点这一节我把它们整合成一个完整的工具模块包含 AES 加解密、SHA-256 摘要、HMAC 签名方便你直接抄到一个项目里用。6.1 核心代码清单import CryptoJS from crypto-js; const AES_KEY CryptoJS.enc.Utf8.parse(your-16-byte-key); const AES_IV CryptoJS.enc.Utf8.parse(your-16-byte-iv-); const API_SECRET your-hmac-secret; const EncryptService { // AES 加密返回 Base64 aesEncrypt(plainText) { return CryptoJS.AES.encrypt( CryptoJS.enc.Utf8.parse(plainText), AES_KEY, { iv: AES_IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ).toString(); }, // AES 解密 aesDecrypt(cipherText) { const decrypted CryptoJS.AES.decrypt(cipherText, AES_KEY, { iv: AES_IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return decrypted.toString(CryptoJS.enc.Utf8); }, // SHA-256 摘要常用于敏感字段单向脱敏 sha256(text) { return CryptoJS.SHA256(text).toString(); }, // HMAC-SHA256 签名 hmacSign(message) { return CryptoJS.HmacSHA256(message, API_SECRET).toString(); }, // 把对象转成固定顺序的待签名串 buildMessage(obj) { const keys Object.keys(obj).sort(); return keys.map(k ${k}${obj[k]}).join(); } }; export default EncryptService;6.2 后端还原信息的思路供对照联调后端拿到加密串后还原流程大概是Base64 解码 - 用同一个 key 和 iv 做 AES 解密 - 得到 JSON - 用同一个 hmac secret 复算签名 - 比对。这里最关键的点是密钥和 IV 要提前通过安全通道比如配置中心的加密配置、部署时的环境变量约定好不能随便硬编码到仓库里。6.3 一个现实的决定密钥到底放哪我知道一定有读者想问——密钥放前端不安全干嘛还要写死我的看法是你要先搞清楚威胁模型。如果项目只是为了防止明文被抓包、防止自动化脚本乱搞、满足合规要求那么前端固定密钥方案是可接受的。如果你要防的是“有人扒 JS 逆向你的密钥”那你需要的是设备指纹、动态令牌、后端网关鉴权等一系列更复杂的机制单靠前端加密是扛不住的。我见过不少人一上来要求前端“加密要绝对安全”结果把密钥做成动态从后端获取反而多了一个泄露点。加密这件事先想清楚防谁再决定怎么做。7. 常见报错、乱码与排查技巧实录最后分享一些 Crypto-js 实战中高频出现的问题和排查思路都是我亲手踩过的坑。7.1 “Malformed UTF-8 data” 解密报错出现Malformed UTF-8 data基本可以断定密文被篡改过或者解密用的 key/iv 不对或者密文 Base64 在传输过程中被 URL 解码转了一遍、弄坏了字符。排查步骤先确认密文从后端拿到后有没有经过decodeURIComponent如果传进来被转义了先还原再解再确认 key 和 iv 是否与加密时完全一致尤其是字符串末尾的空格和编码最后确认你解密的内容是不是多个密文拼在一起的Crypto-js 的 AES.decrypt 不接受拼接的密文串。7.2 加密结果每次都不一样是出 bug 了吗如果你用的是CryptoJS.AES.encrypt(msg, password)这种传口令的方式那么 CipherParams 里本身就会包含一个随机盐所以同样的明文每次加密结果不一样是正常的。但如果你传的是 key 和 ivCBC 模式下只要 iv 固定同样的明文加密结果就是一样的——如果一样说明数据太有规律反而说明你没把随机性用好建议让 iv 每次随机生成并把它随密文一起传给后端。7.3 中文参数变成乱码一律检查编码转换器加解密前用CryptoJS.enc.Utf8.parse和CryptoJS.enc.Utf8.stringify不要在中间环节用JSON.stringify直接拼接 WordArray不要把密文用toString()转成 Hex 之后又用Utf8.parse去解析格式混乱会带来一堆玄学问题。7.4 Crypto-js 在部分浏览器上直接引用失败老旧的浏览器或特定的企业安全 WebView 可能不支持某些 API此时先确认控制台报错究竟是加载失败还是执行到某一行才报错。如果是加载失败换 CDN 地址或用本地文件如果是执行报错多半是某些语法不支持可以多关注 Crypto-js 3.x 到 4.x 的兼容性改动。7.5 性能问题大文本加密卡住页面Crypto-js 是纯 JS 计算大文本或者大文件加密时确实会阻塞主线程遇到这种场景不要在主线程直接跑。可以拆分成小片分片加密或者用 Web Worker 处理算完再回传结果。我处理过一个 20MB 文件的摘要计算直接在前端算卡了将近十几秒放到 Worker 里就完全无感。7.6 快速问题定位思路如果签名后端总是验不过按下面这个顺序排查能省很多时间先不验签看后端打印出来的待签名字符串和前端拼的字符串是否完全一致包括空格、大小写、符号顺序确认参与签名的字段过滤规则一致比如空值是否剔除了排序规则是字典序还是 ASCII 序用同一个固定字符串在前端和后端分别算 SHA-256 / HMAC如果结果一致说明算法实现没问题问题在拼串规则如果不一致说明密钥或者字符编码有问题。最后说点实在的和黄橙橙的教学文不一样我写前端加密的真实感受是好用的不是某个炫酷的算法而是一套能在项目里稳定跑通的“约定”。加密方式、密钥格式、编码规则、签名顺序每一样都要在前后端接口文档里写清楚甚至可以直接把加密模块的 JS 文件路径同步给后端同学让他们照着同样的逻辑去实现。Crypto-js 只是一个工具真正决定加密方案好不好用的是你对业务场景的理解和对边界条件的认知。希望这篇文章能让你少走几步弯路特别是那些“半天查不出原因最后发现是编码问题”的坑 —— 提前避开它们你就已经比原来的自己快一步了。
返回列表