上周帮人排查一个登录接口的报文问题,前端传上去的密码对不上,后端解出来是乱码。翻了半天代码,发现他们在同一个文件里同时用了crypto-js的 AES、md5做签名、btoa做编码,还有一段网上抄来的 RSA 公钥加密。四个东西混在一起用,谁负责什么完全没理清 —— 有人把 Base64 当成加密手段在传密码,有人把 MD5 当哈希校验的"安全版本",还有人用 AES 加密之后又用 RSA 加密了一遍。这篇就围绕js aes加密、md5摘要、base64编码、rsa加密这套组合拳展开,把每个角色的职责边界、参数怎么选、代码怎么写、踩坑怎么排查,一次讲透。标题里那个"ras"是手滑,正确写法是 RSA,下面统一用 RSA。不管你是刚接手一个已有加密逻辑的老项目,还是从零开始写一段前后端约定的报文加解密,这里的内容都能直接抄作业,前提是你愿意花十分钟搞明白"为什么要这么写"。
1. 先把四个词摆到正确的位置上
1.1 加密、摘要、编码,三种完全不同的活儿
很多人栽跟头就栽在这句话上:以为能"变形"就是加密。实际上 Aes、MD5、Base64、RSA 这四个东西分属三个完全不同的类别,混用带来的不是性能问题,是安全问题。
加密(AES / RSA)的目标是"别人看不懂"。它有密钥,可逆,解密需要钥匙。AES 是对称的,一把钥匙开一把锁;RSA 是非对称的,公钥加密、私钥解密,或者反过来私钥签名、公钥验签。
摘要(MD5 / SHA 系列)的目标是"内容改没改、对不对得上"。它没有密钥,理论上不可逆,输出定长。MD5 输出固定 128 位(32 个十六进制字符),SHA-256 输出 256 位。它的正确用法是完整性校验和缓存键,不是"把密码加密存起来"。
编码(Base64)的目标是"让二进制数据能塞进文本通道"。它没有密钥,一秒钟就能解开,任何人都能还原。它的作用是让图片、密文、随机盐这些二进制内容能安全地放进 JSON、URL、HTTP 头里,不被转义规则破坏。
我把这三者的关系记成一句顺口溜:编码防变形,摘要防篡改,加密防偷看。一个典型的报文流程是:业务明文 → AES 加密成密文 → Base64 编码成可传输字符串 → 对整个请求做摘要签名 → 可选的 RSA 保护那段 AES 密钥。四个环节环环相扣,缺一个都不会立刻出问题,但一旦出问题就是很隐蔽的那种。
注意:任何把 Base64 当加密用的说法,都是错的。你抓个包,随便找个在线工具一贴就能还原,它连遮挡视线的作用都有限。同理,MD5 密文在彩虹表面前基本等于明文,尤其是没加盐的那十六进制串。
1.2 为什么前端需要这一套组合拳
有人会问,传输层已经有 TLS 了,前端还折腾这一套干什么。这句话一半对一半不对。对的部分是:真正防中间人窃听,靠的是传输层协议,前端 JS 加密在"防专业攻击者"这件事上收益有限 —— 密钥就躺在浏览器内存里,打开开发者工具打个断点全看见了。
不对的部分在于,前端加密在工程上有几类真实价值,是被反复验证过的:
第一是降低明文暴露面。密码、手机号、身份证号、银行卡号这类字段,如果直接躺在请求体里,会被浏览器的网络面板、前端日志上报、埋点 SDK、错误监控、甚至用户手机上的第三方抓包工具完整记录。前端做一层 AES 之后,这些东西就不会以原文形式进入各种日志管道。这是合规审计时最容易被问到的一点。
第二是统一报文形态。很多老系统后端接收的是固定格式的字符串,而不是 JSON 结构,整个报文体加密后传输,能让接口契约保持稳定,前端改字段不影响后端解析。
第三是与服务端已有的安全约定对齐。不少网关、开放平台的签名规则就是"时间戳 + 随机串 + 业务参数排序 + MD5/HMAC 签名",这套规则前端必须复现,否则请求直接被拒。
第四是防重放和防篡改。摘要 / 签名解决的是"这条报文有没有被改过、是不是被重复发送",这个能力是传输层给不了的,必须在应用层自己做。
所以别纠结"前端加密安不安全"这种大问题,把它当成一个降低风险面、对齐契约的工程手段,用它该用的地方,别指望它扛住攻击,思路就顺了。
1.3 方案分层:我在项目里怎么排兵布阵
实际项目里的分工,我给一个可以直接套用的版本:
| 环节 | 技术选型 | 承担职责 | 密钥归属 |
|---|---|---|---|
| 字段级加密 | AES-256-GCM 或 AES-128-CBC | 敏感字段不明文出现 | 前后端共享会话密钥 |
| 密文传输形态 | Base64(URL 安全变体) | 保证密文能进 JSON/URL | 无 |
| 请求完整性 | HMAC-SHA256(旧系统用 MD5 加盐) | 防篡改、防重放 | 共享签名密钥 |
| 密钥分发 | RSA-2048(OAEP 填充) | 保护临时 AES 密钥 | 公钥下发,私钥仅在服务端 |
| 缓存/去重键 | MD5 或 SHA-1 | 判断内容是否相同 | 无 |
这张表的逻辑很直白:AES 干重活(加密大数据),RSA 干细活(只加密那把小钥匙),Base64 干搬运,摘要干活体力和完整性标记。这个结构不是我拍脑袋想的,它是 TLS 握手本身就在用的套路 —— 非对称加密只用来协商对称密钥,真正的业务数据全部走对称加密。因为 RSA 慢,而且有长度上限,AES 快,能加密任意长度数据,各有各的坑。
我见过最离谱的实现,是把整个 30KB 的订单 JSON 直接用 RSA 公钥加密,然后在浏览器里卡死,控制台报Message too long for RSA。这就是没理解 RSA 能力边界的典型后果。后面第 5 节会把这个长度上限的计算过程写清楚。
2. AES 对称加密的关键参数与选择逻辑
2.1 密钥长度、模式、IV、填充四件事
用 AES 只需要记住四个参数,但每一个都有明确的取值理由。
密钥长度:128、192、256 位三档,对应十六进制密钥 32、48、64 个字符,或者字节 16、24、32 个。业务上直接选 256(AES-256),性能差异在现代浏览器里基本感知不到,加密强度上限更高。注意 CryptoJS 传密钥时用CryptoJS.enc.Utf8.parse('16个字符的key'),如果你直接把字符串丢给CryptoJS.AES.encrypt,它会在内部当口令做一次EVP_BytesToKey派生,结果和后端Cipher.getInstance("AES/CBC/PKCS5Padding")用原始密钥算出来的完全不同 —— 这是前后端对不上的第一大原因。
模式:ECB、CBC、CTR、GCM。ECB 绝对不能用于业务数据,它对相同明文块产生相同密文块,加密一张图片你能看出轮廓,加密一批用户手机号你能看出哪些人手机号一样。CBC 是最常见的兼容选择,需要 IV。GCM 是带认证的模式,能同时校验完整性,推荐新项目用。
IV(初始向量):CBC 模式必须有 IV,长度固定 16 字节,而且每次加密都必须随机生成,绝不能固定写死。写死 IV 的后果是:同样的明文永远产生同样的密文,攻击者可以横向比对出相同内容。IV 不是密钥,可以跟随密文一起明文传输,通常做法是iv + 密文拼在一起再 Base64。
填充:AES 分组 16 字节,明文不够整数倍要补。CryptoJS 里叫Pkcs7,Java 里叫PKCS5Padding,其实是同一回事(Java 习惯叫 PKCS5,填充逻辑按 16 字节块走就是 PKCS7)。前后端必须一致,否则解密时会报BadPaddingException或者解出尾部一段乱码。
实操心得:把
key、iv、mode、padding四个参数写进一份接口对接文档,交给后端确认一遍再动笔。我踩过最气的坑就是前端用了Pkcs7、后端用了NoPadding,两边各说各的对,白对了两天。
2.2 CBC 与 GCM 的取舍
CBC 的问题不在加密强度,在于它"只管保密,不管完整"。攻击者可以把两段密文拼接、交换密文块,解密端可能解出合法但被篡改的内容,这叫填充预言攻击的土壤。GCM 自带认证标签(tag),解密时同时校验内容有没有被动过,改一个字节直接抛错,这是它的核心优势。
浏览器端实现 GCM,用原生 WebCrypto 比 CryptoJS 更省事也更安全,因为密钥可以作为不可导出的CryptoKey对象存在内存里,避免被脚本轻易拿到原始字节:
// AES-GCM 加密(浏览器原生 WebCrypto) async function aesGcmEncrypt(plainText, rawKey) { const enc = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', rawKey, { name: 'AES-GCM' }, false, ['encrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(12)); // GCM 推荐 12 字节 const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, tagLength: 128 }, key, enc.encode(plainText) ); // iv + 密文(含 tag) 一起做 base64 return toBase64(concat(iv, new Uint8Array(ct))); }CBC 在 CryptoJS 里的写法:
const CryptoJS = require('crypto-js'); function aesCbcEncrypt(plainText, keyStr) { const key = CryptoJS.enc.Utf8.parse(keyStr); // 必须 16/24/32 字节 const iv = CryptoJS.lib.WordArray.random(16); // 每次随机 const encrypted = CryptoJS.AES.encrypt( CryptoJS.enc.Utf8.parse(plainText), key, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); // iv 以十六进制或 base64 拼在密文前 return iv.toString(CryptoJS.enc.Base64) + ':' + encrypted.toString(); }选型建议:新项目、能要求后端配合的,直接上 GCM;对接老后端、对方只支持 CBC 的,用 CBC,但一定要把 IV 随机化,并且在应用层用签名补上完整性校验这一环。CBC 不加签名的组合,在安全评审里是要被挑出来的。
2.3 密钥从哪来:前端对称加密最大的坑
这是整套方案里最难的一环,也是最容易被写歪的一环。三种常见做法,优劣很分明。
做法一:把密钥硬编码在前端代码里。打一次包,密钥就在 bundle 里,压缩混淆也只是增加一点点阅读成本。这种做法的真实价值只有一个:防止明文进入日志和网络面板,属于"防君子不防小人"。业务上如果只是为了避免手机号、身份证在日志里裸奔,这个方案成本最低,可以接受,但要在文档里写清楚它不抗攻击。
做法二:登录后由服务端下发一次性会话密钥。服务端生成一个随机 AES 密钥,用 RSA 公钥加密后下发,前端用私钥解……等等,前端没有私钥,公钥加密的东西只有服务端能解。所以正确方向是反的:前端生成随机 AES 密钥,用服务端的 RSA 公钥加密后上传,服务端用私钥解出来,双方建立本次会话的对称密钥。这条链路是安全的,因为 RSA 公钥可以公开,私钥从不离开服务端。这就是第 5 节要讲的混合加密。
做法三:每次请求随机生成密钥,随请求一起用 RSA 加密传输。这是做法二的简化版,无状态,适合网关型接口。缺点是每个请求都要做一次 RSA 运算,RSA-2048 在低端手机上大概 2~8 毫秒,对高频接口不友好,但对登录、支付、实名认证这类低频高敏接口完全够用。
我的建议很明确:低频高敏接口用做法三,高频业务接口用做法二 + 会话密钥缓存,硬编码只留给"纯粹防日志明文"这种低要求场景,并且明确在文档里标注它的安全等级。
3. MD5 摘要:能用、该用、别乱用
3.1 MD5 到底解决了什么问题
MD5 的输出是 128 位定长值,同一份输入永远得到同一个输出,输入改一个字符输出面目全非(雪崩效应)。它的工程价值在于快速比对:判断两个文件是否相同、判断请求参数有没有被改过、给缓存内容生成 key、给前端资源生成指纹。
很多人对 MD5 的态度走了两个极端:一边是"MD5 已经不安全了完全不能用",一边是"MD5 加密密码很方便"。这两种看法都偏了。
MD5 被诟病的核心是碰撞:能构造出两段不同内容但 MD5 相同的结果。这对"签名验签"是致命的 —— 攻击者可以换掉报文内容而保持签名不变。所以做完整性签名时,优先用 HMAC-SHA256,别用纯 MD5。但如果你的用途是"给 10 万条数据生成去重键"或者"文件名哈希",碰撞风险在这个规模下基本可以忽略,MD5 的速度优势还是香的。
至于"MD5 加密密码",问题不在 MD5 本身,在于是否加盐和迭代。裸 MD5 的密码库一旦泄露,彩虹表几分钟就能还原出大部分弱口令。正确做法是加随机盐 + 多次迭代,或者干脆换成专为密码设计的慢哈希(bcrypt / scrypt / Argon2)。前端能做的最有意义的事是:把密码先做一次 SHA-256 或 MD5 摘要再传输,避免明文密码进入日志,真正的存储安全交给后端。
3.2 加盐、HMAC 与替代方案
加盐摘要的写法很朴素,把盐拼在明文前后各做一次,或者做多轮:
function saltedMd5(text, salt) { let h = CryptoJS.MD5(salt + text).toString(); for (let i = 0; i < 3; i++) { h = CryptoJS.MD5(h + salt).toString(); } return h; }HMAC是更规范的做法,它用密钥参与运算,输出不可伪造。签名场景一律用它:
const CryptoJS = require('crypto-js'); function hmacSign(params, secret) { // 参数按字典序拼接,避免顺序不一致导致签名不同 const raw = Object.keys(params).sort() .map(k => `${k}=${params[k]}`) .join('&'); return CryptoJS.HmacSHA256(raw, secret).toString(CryptoJS.enc.Hex); }这里有个细节值得单独拎出来:参数排序。前后端的签名算法最容易出现的不一致,就是 JS 对象的遍历顺序和 Java 的 Map 遍历顺序不同。统一按 key 的字典序(localeCompare或直接sort())拼装,是唯一可靠的做法。
另一个细节是空值和大小写。参数值为null、空字符串、undefined时,拼进去还是跳过,必须约定死。数字类型1和字符串"1"拼出来一样,但true和"true"就未必,这些边界一定要在接口文档里写清楚。
3.3 实操:摘要与校验代码
一个完整的"防篡改 + 防重放"请求头,我给你一个能直接抄的结构:
function buildSignedHeaders(payload, secret, appKey) { const timestamp = String(Date.now()); const nonce = Math.random().toString(36).slice(2, 10); // 8位随机串 const body = JSON.stringify(payload); const bodyMd5 = CryptoJS.MD5(body).toString(CryptoJS.enc.Hex); const raw = `appKey=${appKey}&nonce=${nonce}&ts=${timestamp}&bodyMd5=${bodyMd5}`; const sign = CryptoJS.HmacSHA256(raw, secret).toString(CryptoJS.enc.Hex); return { 'Content-Type': 'application/json', 'X-App-Key': appKey, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Sign': sign }; }服务端验签流程对应四步:校验时间戳与当前时间差是否超过阈值(比如 5 分钟),校验 nonce 是否已使用(Redis 存 5 分钟),用同样的规则重算摘要和签名,比对签名。四步缺一步都能被重放。
注意:MD5 输出有大小写两种形态,
toString()默认小写,Java 的DigestUtils.md5Hex也是小写,但有些老项目会toUpperCase()。签名比对时统一转小写再比,能避开大量"签名不对"的无效排查。
4. Base64 编码:别把它当加密
4.1 编码原理与三个常见坑
Base64 的原理很简单:把每 3 个字节(24 位)切成 4 个 6 位的组,每组映射到一张 64 个字符的表(A-Z、a-z、0-9、+、/),不够 3 字节的用=补齐。所以 Base64 编码后体积膨胀约 33%,一个 30KB 的 JSON 编码后大约 40KB。
坑一:中文直接btoa()报错。btoa只接受 Latin-1 范围内的字符,中文字符直接抛InvalidCharacterError。正确姿势是先转 UTF-8 字节再编码:
// 浏览器端:支持中文的 base64 function toBase64(str) { const bytes = new TextEncoder().encode(str); let bin = ''; bytes.forEach(b => bin += String.fromCharCode(b)); return btoa(bin); } function fromBase64(b64) { const bin = atob(b64); const bytes = Uint8Array.from(bin, c => c.charCodeAt(0)); return new TextDecoder().decode(bytes); }坑二:服务端 Base64 解码器自动换行。Java 的Base64.getMimeEncoder()每 76 个字符插一个\r\n,Node 的Buffer.from(str, 'base64')遇到换行一般能容错,但有些严格的 JSON 解析、URL 参数传递会把换行变成空格或%0A,导致解码失败。传输前记得补一句b64.replace(/[\r\n]/g, ''),服务端解码统一用Base64.getDecoder()而不是 MIME 版本。
坑三:密文里出现+和/会被 URL 转义。把 Base64 密文拼进 URL 查询参数时,+会被解析成空格,/可能被当成路径分隔符。这是"密文放 URL 里后端解不出来"的经典原因。解决办法就是下一节的 URL 安全变体。
4.2 URL 安全变体与前后端对齐
URL 安全 Base64 只做三处替换:+换成-,/换成_,去掉末尾的=填充。解码时反过来补回去:
function base64UrlEncode(b64) { return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function base64UrlDecode(s) { let b64 = s.replace(/-/g, '+').replace(/_/g, '/'); while (b64.length % 4) b64 += '='; return b64; }前后端对齐的检查表,我一般是这么过一遍的:
| 检查项 | 前端常见写法 | 后端常见写法 | 是否一致 |
|---|---|---|---|
| 字符集 | TextEncoder默认 UTF-8 | new String(bytes, StandardCharsets.UTF_8) | 必须都是 UTF-8 |
| 是否去填充 | 部分库会去掉= | 严格解码器要求补齐 | 传输前统一去,解码时统一补 |
| 变体 | URL-safe (-_) | 标准 (+/) | 明确约定用哪个 |
| 换行 | JS 不产生 | MIME 编码器会产生 | 服务端用基础解码器 |
实操心得:调试加解密问题,第一步永远不是看算法,而是把"前端加密后 Base64 的字符串"和"后端期望的字符串"两个值打印出来,逐字符对比长度和首尾。90% 的问题在这一步就能定位,剩下的 10% 才是填充和密钥。
5. RSA 非对称加密的正确打开方式
5.1 密钥长度、填充方案与分段长度计算
RSA 有两个硬约束,不理解这两个就会一直报错。
约束一:密钥长度决定单次能加密的明文上限。2048 位密钥 = 256 字节的模数空间,但要扣掉填充开销。以 PKCS#1 v1.5 填充为例,固定开销 11 字节,所以单次最多加密256 - 11 = 245 字节。1024 位密钥对应 128 字节,最多 117 字节。OAEP 填充开销更大:SHA-1 版本是 42 字节(2048 位下上限 214 字节),SHA-256 版本是 66 字节(上限 190 字节)。所以一个 300 字节的中文串用 RSA-2048 + OAEP-256 直接加密,必然报长度错误。(严格来说不是"必然报错",取决于库的实现,但绝大多数库会。RSA 本身理论上可以加密到模数长度减一,但实际库为了安全会强制填充校验。)
约束二:填充方案决定互操作性。前端常用的jsencrypt默认用 PKCS#1 v1.5,Java 的Cipher.getInstance("RSA")默认也是 v1.5,两边能对上。但新一些的库(如node-forge、WebCrypto 的RSA-OAEP)默认走 OAEP,两边填充不一致就会解出乱码或抛decryption error。这条必须写进对接文档。
| 密钥长度 | 填充方案 | 单次明文上限 | 适用场景 |
|---|---|---|---|
| 1024 位 | PKCS#1 v1.5 | 117 字节 | 老系统兼容,新项目不建议 |
| 2048 位 | PKCS#1 v1.5 | 245 字节 | 前端 jsencrypt 默认 |
| 2048 位 | OAEP-SHA1 | 214 字节 | 兼容性较好的安全选择 |
| 2048 位 | OAEP-SHA256 | 190 字节 | 推荐 |
| 4096 位 | OAEP-SHA256 | 446 字节 | 密钥分发,性能开销大 |
看到这张表就该明白:RSA 天生不适合加密业务数据,245 字节连一个订单号加手机号都塞不下。它的正确用法是加密一把 32 字节的 AES 密钥 —— 32 字节远小于 190 字节的上限,完美契合。
如果真的非要加密长文本(比如对接一个只支持 RSA 的老接口),就得手动分段:按 245 字节切块,逐块加密,每块密文都是 256 字节,拼起来再 Base64。分段逻辑不难,但要注意用 UTF-8 字节切而不是按字符切,否则会把一个中文字符劈成两半,解出来是乱码。
5.2 前端 RSA 加密的完整实现
用jsencrypt是最省事的路子,公钥用标准的 PEM 格式,去掉头尾和换行:
import JSEncrypt from 'jsencrypt'; const PUBLIC_KEY = `MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAL...`; // 纯 base64 主体 function rsaEncrypt(text) { const encryptor = new JSEncrypt(); encryptor.setPublicKey(`-----BEGIN PUBLIC KEY-----\n${PUBLIC_KEY}\n-----END PUBLIC KEY-----`); const result = encryptor.encrypt(text); if (!result) throw new Error('RSA 加密失败,检查公钥格式或明文长度'); return result; // 已经是 base64 }三个容易踩的点:公钥必须带 PEM 头尾,否则setPublicKey静默失败返回false;明文长度超过 245 字节直接返回false,错误信息很不友好,所以包一层自定义异常很有必要;encrypt返回的已经是 Base64 字符串,不要再编码一次。
如果用的是原生 WebCrypto,写法是异步的,公钥要转成 SPKI 格式的 ArrayBuffer:
async function rsaOaepEncrypt(text, spkiBase64) { const bin = Uint8Array.from(atob(spkiBase64), c => c.charCodeAt(0)); const publicKey = await crypto.subtle.importKey( 'spki', bin, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] ); const ct = await crypto.subtle.encrypt( { name: 'RSA-OAEP' }, publicKey, new TextEncoder().encode(text) ); return btoa(String.fromCharCode(...new Uint8Array(ct))); }WebCrypto 的优点是密钥对象不可导出,缺点是 API 全是 Promise,而且填充方案默认 OAEP,和老系统的 v1.5 不兼容。选之前先问后端用的是什么填充。
注意:公钥放在前端是安全的,它本来就是公开的。但如果有人在打包产物里看到
-----BEGIN RSA PRIVATE KEY-----,那就是严重的生产事故了。上线前一定要扫一遍 bundle,确认没有私钥泄漏。
5.3 混合加密:把 AES 和 RSA 拼起来
这是整套方案的核心,也是我最推荐的落地形态。流程分五步:
- 前端生成 32 字节随机密钥
sessionKey(用crypto.getRandomValues,别用Math.random); - 前端用服务端 RSA 公钥加密
sessionKey,得到encryptedKey(Base64); - 前端用
sessionKey对业务明文做 AES-GCM 加密,得到iv + 密文的 Base64; - 组装请求体
{ encryptedKey, data, ts, nonce, sign },其中sign对前三者做 HMAC; - 服务端用私钥解出
sessionKey,再用它解data,验签通过后处理业务。
async function secureSubmit(payload, rsaPubKey, signSecret) { // 1. 随机会话密钥 const sessionKey = crypto.getRandomValues(new Uint8Array(32)); // 2. RSA 保护会话密钥 const encryptedKey = await rsaOaepEncrypt(toBase64(sessionKey), rsaPubKey); // 3. AES-GCM 加密业务数据 const data = await aesGcmEncrypt(JSON.stringify(payload), sessionKey); // 4. 组装 + 签名 const ts = String(Date.now()); const nonce = Math.random().toString(36).slice(2, 10); const raw = `${encryptedKey}|${data}|${ts}|${nonce}`; const sign = CryptoJS.HmacSHA256(raw, signSecret).toString(); return { encryptedKey, data, ts, nonce, sign }; }这个结构的好处:会话密钥一次一密,即使某次请求的密钥泄漏,也只影响那一次;AES 处理任意长度的业务数据,没有长度焦虑;RSA 只加密 32 字节,性能开销可以忽略;签名保证报文没被改过;时间戳 + nonce 防重放。四个安全目标一次满足,代码量也就二十来行。
代价是服务端要配合做解密和验签,且每次请求多两次非对称运算。如果接口 QPS 很高,可以把sessionKey缓存在内存里,登录时协商一次,之后复用,过期时间设成 30 分钟,配合服务端定期轮换。
6. 常见问题与排查实录
6.1 排查清单与速查表
加解密问题是典型的"现象一致、原因各异",靠猜是猜不出来的,必须有顺序地排查。我踩过的坑整理成表,遇到问题按顺序对照:
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
| 后端解密报填充错误 | padding 不一致 | 双方都改成 PKCS7/PKCS5 |
| 解密出来尾部有乱码 | 明文长度不足块大小、填充被吃 | 检查是否用了NoPadding |
| 密文每次都不一样 | IV 随机(正常现象) | 确认 IV 已随密文传输 |
| Base64 解码后长度不对 | 有换行、URL 转义丢失 | 打印原始字符串逐字符比对 |
| RSA 返回 false | 明文超长或公钥格式错 | 检查 PEM 头尾、长度是否超 245 |
| 中文解密乱码 | 字符集不是 UTF-8 | 统一TextEncoder/ UTF-8 |
| 签名校验不通过 | 参数顺序、大小写、空值处理 | 打印双方拼接后的原文对比 |
| 偶发解密失败 | 并发下 IV 复用或密钥覆盖 | 检查 IV 是否每次新生成 |
6.2 几个踩过的坑
坑一:CryptoJS 的密钥派生。前面提过一次,但值得再强调。CryptoJS.AES.encrypt(text, 'mySecretKey123')里的第二参数如果是字符串,CryptoJS 会当成口令,走EVP_BytesToKey加盐派生,生成一个和字符串本身完全无关的密钥。后端拿"mySecretKey123"的字节去解,永远解不开。必须显式CryptoJS.enc.Utf8.parse()转成 WordArray。
坑二:WordArray 和 Uint8Array 互转丢数据。CryptoJS 的WordArray是 32 位字数组,转成Uint8Array时如果直接用words遍历,大端序会出问题。标准写法是:
function wordArrayToBytes(wordArray) { const words = wordArray.words; const sigBytes = wordArray.sigBytes; const bytes = new Uint8Array(sigBytes); for (let i = 0; i < sigBytes; i++) { bytes[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; } return bytes; }同理,从字节流转回 WordArray 也有对应的写法。这两个函数我建议放在项目公共工具里,别每次现写。
坑三:时间戳精度问题。Date.now()是毫秒,但有些后端按秒校验,1700000000000会被判成"时间戳超出范围"。对接时先确认单位,必要时Math.floor(Date.now() / 1000)。
坑四:本地开发环境和线上表现不一致。有些项目的密钥按环境区分,本地用测试密钥,线上用生产密钥,但process.env在打包时被静态替换,改配置不清缓存就会用到旧值。遇到"代码没改但解密突然失败",先怀疑环境变量和构建缓存。
7. 工程化落地与性能、安全边界
7.1 前端加密的能力边界
必须承认一件事:前端代码是完全暴露在用户设备上的。任何密钥、任何算法,用户都有能力看到。所以前端加密的价值定位是"提高攻击成本、降低明文暴露面、满足审计要求",而不是"绝对安全"。
基于这个定位,有几条实践原则:
不把长期密钥硬编码进打包产物。会话密钥随请求生成,公钥可以硬编码(它本来就是公开的),签名密钥如果必须放前端,就只用于低频接口,且服务端要做频率限制和异常检测。
敏感字段的加密只做一层就够,不要叠加。AES 加密完再 Base64 再 RSA 再 Base64,除了增加维护成本和出错概率,安全性没有实质提升。加一层 AES-GCM 已经能覆盖保密和完整性,外层套签名解决防篡改和防重放。
日志和监控必须做脱敏。加密做完之后,最容易泄漏的地方变成console.log(JSON.stringify(payload))和错误上报 SDK。生产构建里要把console干掉,错误上报的请求体做白名单采集。这一条比加密算法本身更影响实际安全水位。
7.2 性能与体积
引入crypto-js全量包大约 200KB+,压缩后仍有几十 KB,对一个首屏就要求加载的页面来说不划算。三个优化方向:
第一,按需引入。crypto-js支持单模块引入(crypto-js/aes、crypto-js/md5、crypto-js/hmac-sha256),能砍掉一大半体积。第二,优先用原生 WebCrypto。AES-GCM、SHA-256、RSA-OAEP 浏览器原生都支持,异步 API,零体积,密钥还能不可导出。第三,把加解密逻辑放到 Web Worker 里。AES 加密一个 1MB 的字符串在主线程上大概几十毫秒,加上 JSON 序列化会造成明显卡顿,挪到 Worker 里就不影响交互。
性能实测的经验值供参考:AES-GCM 加密 10KB 文本,浏览器原生大约 0.5~2 毫秒,CryptoJS 大约 5~15 毫秒;RSA-2048 公钥加密一次,jsencrypt大约 3~10 毫秒,低端机上可能到 20 毫秒。所以 RSA 千万别放进循环里,也别对列表里每一条记录单独做一次 RSA。
7.3 一个可以直接复用的封装思路
最后给一个工程化的封装结构,把四种能力收在一个模块里,统一错误处理和日志:
// secure.js —— 统一出口 export const Secure = { aesEncrypt, aesDecrypt, // AES-GCM 主加密 md5, hmacSign, verifySign, // 摘要与签名 b64Encode, b64Decode, // URL 安全 Base64 rsaEncrypt, rsaDecrypt, // 仅密钥分发 buildSecurePayload // 混合加密组装 };每个方法内部做三件事:参数校验(密钥长度、明文类型)、异常包装(把库的底层错误转成可读信息)、调试开关(开发环境打印中间值,生产环境静默)。这层封装看起来是"多写了一层",但它挡掉的是后面无数次"到底是哪一步错了"的排查。
我自己在这个模块上迭代了三个版本,最后悔的是第一版没有统一 Base64 的处理,前端各处btoa、CryptoJS.enc.Base64、Buffer.from().toString('base64')混着用,同一个项目里出现了三种编码结果,排查一次问题要翻五个文件。统一到一个出口之后,所有加解密问题的定位时间从平均两小时降到十分钟以内。这套东西说到底不难,难的是把它约束在一个地方、不让它散落成一地。