上周帮一位朋友排查他负责的一个登录模块,后端同事的原话是“你传过来的密文我解出来是乱码,还有一段是空字符串”。翻到前端代码我就乐了:btoa套了一层、md5套了两层、AES 的密钥直接写了个 8 位字符串、RSA 那段干脆把公钥私钥一起怼进了代码里。这四个词——JS AES 加密、MD5 摘要、Base64 编码、RSA 加密——几乎每个前端项目都会碰到,但它们经常被当成同一个东西混着用,于是问题就在联调那天集中爆发。
这篇不讲抽象的密码学理论,就按实际项目里踩过的顺序,把这四个工具各自的职责边界、参数对齐方式、以及串起来用的时候最容易翻车的地方讲透。不管你是第一次写加密相关代码,还是正在跟后端对着一堆乱码互相甩锅,下面的内容都可以直接照着改。
1. 先把四件事分清楚:AES、MD5、Base64、RSA 各管一段
很多人第一次接触这组词,是因为看到别人的项目里写了这么一串代码,于是照抄过来,却不知道每一行在干什么。结果就是:该加密的地方做了编码,该做摘要的地方做了加密,该用非对称的地方用了对称,整个链路从第一步就跑偏了。
1.1 一张表看清四个工具的职责边界
先立一个总纲。这四个东西分属三个完全不同的类别,把它们混在一起讨论,就像把“上锁”“按手印”“换信封”“配钥匙”当成同一件事。
| 名称 | 类别 | 是否可逆 | 是否需要密钥 | 核心作用 | JS 侧常见实现 |
|---|---|---|---|---|---|
| Base64 | 编码 | 完全可逆,且不需要任何密钥 | 否 | 把任意字节转换成可安全放进文本协议(JSON、URL、Header)的字符 | btoa/atob、Buffer、CryptoJS.enc.Base64 |
| MD5 | 哈希摘要 | 不可逆(设计上) | 否 | 生成固定长度指纹,用于完整性校验、去重、缓存键 | CryptoJS.MD5、node:crypto的createHash |
| AES | 对称加密 | 可逆,同一把密钥加解密 | 是,加解密共用一把 | 保护数据内容本身不被看到 | CryptoJS.AES、WebCryptosubtle、node:crypto |
| RSA | 非对称加密 | 可逆,公钥加密、私钥解密 | 是,一对密钥 | 在不可信通道里安全传递密钥、做数字签名验签 | jsencrypt、WebCryptosubtle、node:crypto |
看表格能立刻读出一个关键结论:Base64 和 MD5 都不带密钥。这意味着它们提供不了任何“保密性”。Base64 只是换了字符集,谁拿到都能还原;MD5 只是压缩成指纹,谁也还原不回去,但可以被撞。
所以“把密码用 Base64 编码一下再传”这种做法,本质上等同于明文传输,只是让明文长得不像明文。这是我在代码评审里见得最多、也最容易让新人误判的一点。
1.2 为什么前端加密绕不开“密钥在客户端”这件事
聊到这必须先把一个底层事实摆明:只要加密逻辑跑在浏览器里,密钥就必然存在于用户机器上。打开开发者工具、翻一下打包后的产物、或者在关键函数上打个断点,密钥就是可见的。区别只在于攻击者需要花五分钟还是五十分钟。
这不代表前端加密没有意义,而是要把它的定位摆正:
- 传输层的问题归传输层。链路上防止中间人看到明文,靠的是 TLS,不是前端手写的 AES。
- 前端加密解决的是“业务字段别在请求体里赤裸裸出现”。比如日志采集系统会把请求体整体打进去,明文手机号、身份证号就会散落到各处;这时做一层字段级加密,能显著缩小暴露面。
- 前端加密还能提高自动化脚本的成本。想批量构造请求,至少得把加密逻辑跑一遍。
我一般的判断标准是:如果这个字段泄漏后会导致“用户可以看到别人的数据”,那前端加密救不了你,必须靠服务端鉴权;如果这个字段泄漏后只是“敏感信息多暴露一层”,那前端加密是值得做的加固。
至于 RSA 为什么要出现在前端——它的角色不是“保护数据”,而是“保护那把真正干活的对称密钥”。这个分工在第六节会展开。
2. Base64 只是换了身衣服:字符集映射、体积膨胀和 URL 传输翻车
Base64 是这四者里最简单的一个,也恰恰是最容易被当成“加密”的那一个。它的原理一句话能说完:把每 3 个字节(24 位)切成 4 组 6 位,每组映射到 64 个可打印字符里的一个。因为 24 能被 6 整除,所以 3 字节变 4 字符,体积固定膨胀 1/3。
2.1 从字符串到字节再到 Base64 的完整链路
中间那句“转成字节”是最容易被忽略的一步。Base64 的处理对象是字节序列,不是字符。而 JS 字符串是 UTF-16 的抽象,一个汉字在字符串里是一个长度单位,转成 UTF-8 字节就是 3 个字节。这条转换链一旦错位,结果就是乱码或者抛错。
浏览器原生的btoa有个硬限制:它只接受 Latin-1 范围内的字符(码点 0–255),传入任何中文都会抛InvalidCharacterError。所以正确写法是先编码再转字节:
// 字符串 -> UTF-8 字节 -> Base64 function utf8ToBase64(str) { const bytes = new TextEncoder().encode(str); // Uint8Array let bin = ''; const CHUNK = 0x8000; // 分块,避免 apply 参数过多爆栈 for (let i = 0; i < bytes.length; i += CHUNK) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); } return btoa(bin); } // Base64 -> UTF-8 字节 -> 字符串 function base64ToUtf8(b64) { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return new TextDecoder().decode(bytes); }CHUNK那段不是多余的。String.fromCharCode.apply(null, hugeArray)在字节数超过十几万时会因为参数过多直接抛RangeError: Maximum call stack size exceeded。这个坑在处理长文本或者把图片转 Base64 时特别容易出现,而且报错信息看起来跟 Base64 毫无关系,很容易查偏方向。
Node 侧就简单得多,因为Buffer天然就是字节容器:
const b64 = Buffer.from('中文内容', 'utf8').toString('base64'); const text = Buffer.from(b64, 'base64').toString('utf8');2.2 + / = 三个字符在 URL 和表单里的翻车现场
标准 Base64 的字符表里有+、/,末尾还有补位的=。这三个字符放进 URL 查询串里就是灾难:+在application/x-www-form-urlencoded语义下会被解析成空格,/会被某些路由当成路径分隔符,=会被当成键值对的分隔符。
实际表现是:你加密出来的密文传给后端,后端解密报“长度不对”或者“前几位是乱码”。原因就是那串 Base64 里的+到了后端已经变成空格了。
两种处理方式,我一般用第一种:
- URL 安全 Base64(Base64URL):把
+换成-,/换成_,去掉末尾的=。这是 JWT 用的方案,前后端都好约定。 - 对整串做
encodeURIComponent:也能解决,但会让密文再长一截,而且有些老系统在解码时会二次处理。
function toBase64Url(b64) { return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function fromBase64Url(s) { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; return s; }提示:即使做了 Base64URL,把密文塞进 URL 之前也建议再过一次
encodeURIComponent,尤其是在密文可能包含其他特殊字符、或者要拼进长查询串的场景里。多这一层不吃亏,少这一层可能就得排查半天。
2.3 33% 膨胀与超长字符串的性能代价
Base64 的体积代价是固定的 4/3。听起来不多,但如果你的场景是“把一张 2MB 的图片转成 Base64 塞进 JSON”,那就变成了 2.7MB 的字符串,还要经过一次字符串拼接和一次 JSON 序列化。这三步叠加,主线程能卡住几百毫秒。
我在一个图片预览功能里实测过:把 1.5MB 的图直接FileReader.readAsDataURL拿到 Base64,再塞进一个响应式状态里,低端机上会有肉眼可见的掉帧。后来改成只传Blob,用URL.createObjectURL生成临时地址,问题立刻消失。
结论很朴素:Base64 是给“必须走文本通道”的场景准备的,比如 JSON 字段、HTTP Header、配置文件。如果你有二进制通道(FormData、Blob、ArrayBuffer),就别绕这一圈。另外还有个隐性的坑:Base64 解码不会校验内容有效性,一串随便拼的字符只要能通过字符集检查就能解出一堆垃圾字节,它不会告诉你“这不是我加密的”。
3. MD5 摘要:从“密码加密”退回到“内容指纹”的定位调整
MD5 在中文技术圈里长期被叫作“MD5 加密”,这个叫法本身就是误解的源头。它不是一个加密算法,它是一个哈希函数:输入任意长度,输出固定 128 位(16 字节),常见的十六进制表示是 32 个字符。
3.1 MD5 输出的 32 个十六进制字符是怎么来的
128 位 = 16 字节,每个字节用两位十六进制表示,正好 32 个字符。如果换成 Base64 表示,同样 16 字节就是 24 个字符(含补位),所以你会看到有些系统里 MD5 结果是 24 位的 Base64 串——那不是另一种算法,只是另一种表示。
const hex = CryptoJS.MD5('abc').toString(CryptoJS.enc.Hex); // 900150983cd24fb0d6963f7d28e17f72 const b64 = CryptoJS.MD5('abc').toString(CryptoJS.enc.Base64); // kAFQmDzST7DWlj99KOF/cg==这里有个非常典型的联调事故:前端取了.toString()拿到的是 hex,后端拿到后先做了一次 Base64 解码再比对,结果永远对不上。所以约定摘要结果时,要约定到“表示形式”这一层,而不是只说“用 MD5 校验”。我在接口文档里会明确写成“取 MD5 的小写十六进制字符串”,大小写也要写清楚,因为有不少老代码是拿ToUpper()比对的。
MD5 的另一个特性是“雪崩效应”:输入改一个字符,输出面目全非。这让它非常适合做两件事——内容指纹和快速去重。比如前端根据“表单字段的 JSON 串”算一个 MD5 当作缓存键,只要字段没变,键就不变,这一招在复杂的筛选条件下特别省事。
3.2 加盐能提升多少成本,边界在哪
“密码先 MD5 一次再拼上盐再 MD5 一次”这套写法,在十年前的项目里到处都是。它确实比裸 MD5 强一点:裸 MD5 的问题是同密码同结果,一旦数据库被拖走,攻击者拿一张预计算表就能批量还原高频密码。
加盐解决了“预计算”这一层,但没解决根本问题——MD5 太快了。它的设计目标就是高效,现代硬件每秒能算几十亿次量级。这意味着即便加了盐,攻击者针对单个账号做暴力枚举的成本依然很低。
所以对密码存储,我的态度很明确:前端做的任何摘要都不算安全措施,真正的密码哈希必须在服务端用专门的慢哈希完成。前端那一层 MD5 的唯一价值是“避免明文密码出现在请求体里”,属于降低暴露面的加固,不是安全边界。
3.3 什么时候应该换成 SHA-256 或 HMAC
判断标准其实很好记:只要这个摘要的结果会参与“信任判断”,就该换掉 MD5。
| 场景 | 是否适合 MD5 | 建议方案 |
|---|---|---|
| 缓存键、列表项去重、内容指纹 | 可以 | MD5 足够,性能好 |
| 静态资源文件名带指纹 | 可以 | MD5 前 8 位就够 |
| 接口参数签名 | 不合适 | HMAC-SHA256 |
| 密码存储 | 不合适 | 服务端专用慢哈希算法 |
| 文件完整性校验(防篡改) | 不合适 | SHA-256 |
HMAC 和普通哈希的区别在于它把密钥掺进了计算过程。普通MD5(参数串 + 密钥)这种“拼接式签名”存在长度扩展问题,而且拼接顺序一变语义就变了;HMAC 有明确的两轮结构,能规避这些问题。JS 侧用 WebCrypto 几行就能写:
async function hmacSha256(keyStr, msgStr) { const enc = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', enc.encode(keyStr), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const sig = await crypto.subtle.sign('HMAC', key, enc.encode(msgStr)); return [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, '0')).join(''); }4. AES 真正容易出错的地方:密钥、模式、IV、填充四重对齐
AES 是这套组合里的主力,也是联调事故的重灾区。它的参数多,而且每个参数只要有一处对不上,结果就是“解出来是乱码”这么一个毫无信息量的报错。
4.1 crypto-js 把字符串密钥悄悄当口令用的那次事故
这是我最想强调的一个坑。看这段代码:
CryptoJS.AES.encrypt('明文', 'mysecretkey123').toString();看起来密钥是mysecretkey123,实际上 crypto-js 在key是字符串时,会把它当成口令(passphrase),走一套 OpenSSL 兼容的密钥派生流程:随机生成一个 8 字节 salt,用 EvpKDF 派生出真正的 key 和 iv,输出格式是"Salted__"+ salt + 密文,再做 Base64。
后果有两个:一是每次加密结果都不一样(因为 salt 随机),二是后端如果按“raw key”去解,必然失败。我见过最离谱的一次,是前端每次刷新页面密文都变,双方都以为是加密逻辑有问题,查了整整一个下午。
正确做法是把 key 和 iv 都显式构造成 WordArray:
const key = CryptoJS.enc.Utf8.parse('0123456789abcdef'); // 16 字节 = AES-128 const iv = CryptoJS.enc.Utf8.parse('abcdef0123456789'); // 16 字节,必须显式传 const cipherParams = CryptoJS.AES.encrypt( CryptoJS.enc.Utf8.parse('要加密的明文'), key, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); const cipherB64 = cipherParams.toString(); // 纯密文的 Base64 const decrypted = CryptoJS.AES.decrypt( cipherB64, key, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); const plain = decrypted.toString(CryptoJS.enc.Utf8); // padding 不对这里会抛错注意CryptoJS.enc.Utf8.parse('要加密的明文')这一步不能省。如果直接传字符串,crypto-js 会走另一条处理路径,虽然大多数情况下结果一样,但遇到非 ASCII 字符时行为可能出现差异,不如统一成字节。
密钥长度直接决定算法强度:16 字节是 AES-128,24 字节是 AES-192,32 字节是 AES-256。长度不对会直接报错,但如果用Utf8.parse解析一个长度不对的字符串,有些版本会静默截断或补零,这就更危险了——你以为在用 AES-256,实际用的是 AES-128。
4.2 CBC 与 GCM 的取舍,以及 IV 该怎么传
| 模式 | crypto-js 支持 | 机密性 | 完整性校验 | 推荐度 |
|---|---|---|---|---|
| ECB | 支持 | 弱(相同明文块产生相同密文块) | 无 | 不要用 |
| CBC | 支持 | 中 | 无(需额外 HMAC) | 兼容老系统时用 |
| CTR | 支持 | 中 | 无 | 特殊场景 |
| GCM | 不支持 | 强 | 自带认证标签 | 首选 |
ECB 的问题是结构性的:相同明文块加密后产生相同密文块,加密一张图片甚至能看出轮廓,所以它在新项目里没有讨论价值。CBC 是生态最广的选择,node:crypto、Java、PHP、Go 都原生支持,联调成本最低。GCM 更强,自带完整性校验,密文被改一个字节解密就会失败,但 crypto-js 不原生支持,得走 WebCrypto。
IV 的处理有两个要点。第一,CBC 模式下 IV 必须是随机且每次不同的,固定 IV 会让相同明文产生相同密文,等于退化成 ECB。第二,IV 不是秘密,可以跟密文一起明文传输,通常就拼在密文前面或者放在一个独立字段里。
// 生成随机 IV,随密文一起传给后端 const iv = CryptoJS.lib.WordArray.random(16);接收方拿到iv之后再参与解密。这就引出一个非常常见的错位:约定里写“密文放data字段”,结果后端默认data里只有密文,前端实际拼了 IV 进去,长度就差了 16 字节,解密直接报错。
4.3 一份前后端对齐的自检清单
联调前把下面这几条逐项对一遍,能省掉八成的来回:
- 算法标识统一写成
AES/CBC/PKCS5Padding这种完整形式,别只写“用 AES”。 - key 的长度和编码写死并双方确认(例如“UTF-8 编码后 32 字节,对应 AES-256”)。
- IV 的来源、长度、传输位置写清楚(例如“每次随机 16 字节,Base64 后放在
iv字段”)。 - 密文的传输编码写清楚(Base64 还是 hex,URL 安全还是标准)。
- 明文的字符集写清楚(统一 UTF-8,这是唯一现实的选项)。
- 填充方案写清楚(
PKCS5Padding和PKCS7Padding在 16 字节分组下是等价的,但文档上要统一)。
这里补充一个容易忽略的细节:解密的输出别直接当字符串用。如果密钥或 IV 错了,块解密依然能“成功”执行,只是产出的字节是随机的。这时toString(Utf8)大概率会抛Malformed UTF-8 data,但偶尔也能“成功”解出一串乱码。所以判断解密是否成功,不能只看有没有抛错,而是要看解出来的内容是否符合预期结构(比如是不是合法 JSON、有没有约定的魔数字段)。
5. RSA 的物理上限:2048 位密钥一次最多能塞多少字节
标题里写的 “ras” 应该是 RSA 的笔误,指的是那个基于大整数分解难题的非对称算法,名字取自三位研究者的姓氏首字母。它的核心特征是一对密钥:公钥加密的数据只有私钥能解,私钥签的名只有公钥能验。
5.1 PKCS#1 v1.5 与 OAEP 的长度公式
RSA 有个非常容易被忽略的限制:它能加密的数据长度必须小于密钥长度。因为加密结果是一个模运算的结果,整段数据被当成一个大整数处理,超出模长就装不下了。
具体能装多少,取决于填充方案。设密钥模长为 k 字节(2048 位 = 256 字节),不同方案的最大明文长度是:
| 填充方案 | 最大明文长度公式 | 2048 位密钥下的实际值 |
|---|---|---|
| 无填充(仅教科书场景) | k | 256 字节 |
| PKCS#1 v1.5 | k − 11 | 245 字节 |
| OAEP + SHA-1 | k − 2×20 − 2 | 214 字节 |
| OAEP + SHA-256 | k − 2×32 − 2 | 190 字节 |
按 UTF-8 中文每字 3 字节算,2048 位 + PKCS#1 v1.5 的情况下,最多只能加密 81 个左右的汉字。这就是为什么“用 RSA 加密整段业务数据”这个想法从根上就不成立——稍微长一点的字段就会直接报错。
顺带说一句密钥长度选择:1024 位现在属于不安全区间,不要再用于新系统;2048 位是当前的主流基线;4096 位能提升安全余量,但加解密耗时大约是 2048 位的数倍,在前端会造成可感知的卡顿。所以在浏览器里跑 RSA,2048 位基本是唯一务实的选择。
5.2 jsencrypt 的能力边界与分段加密
前端用 RSA,最常见的库是jsencrypt。它能用的填充方案只有一个:PKCS#1 v1.5。这意味着后端必须配同样的填充,如果对方用了 OAEP,你换库都来不及,只能改方案。
import JSEncrypt from 'jsencrypt'; const encryptor = new JSEncrypt(); encryptor.setPublicKey(`-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A... -----END PUBLIC KEY-----`); const cipher = encryptor.encrypt('短文本'); // 超长会返回 false,而不是抛异常注意这里的返回行为:jsencrypt.encrypt()在明文超长时返回false,不抛错。如果代码里没做判断,false会被当成字符串继续往下传,最后变成"false"塞进请求体,后端收到自然解不出来。我第一次踩到这个坑时,盯着后端日志看了很久才反应过来。
真需要加密长数据时,有两条路。第一条是分段加密:把明文按最大长度切成若干块,每块单独 RSA 加密,结果拼起来。这条路的代价是密文体积膨胀得很夸张,而且每段 RSA 运算都慢,超过三五段就不建议了。第二条路,也是我推荐的——别用 RSA 加密数据本身,用它加密一把对称密钥。这就是下一节要讲的混合加密。
// 分段加密的示意(能用,但不推荐用于大段数据) function rsaEncryptLong(text, encryptor, maxBytes = 245) { const bytes = new TextEncoder().encode(text); const blocks = []; for (let i = 0; i < bytes.length; i += maxBytes) { const chunk = bytes.subarray(i, i + maxBytes); const b64 = btoa(String.fromCharCode.apply(null, chunk)); const out = encryptor.encrypt(b64); // 注意:这里解出来的内容是 Base64,不是原文字节 if (out === false) throw new Error('RSA 分段加密失败'); blocks.push(out); } return blocks.join('.'); }这段代码里有个隐含约定:每段的原文要先转成 Base64 再交给 RSA,因为jsencrypt内部按 UTF-8 处理字符串,直接传二进制会出问题。解码端也要相应地先 RSA 解密、再 Base64 还原、最后拼字节。这套约定必须在接口文档里写清楚,否则一定对不上。
5.3 公钥进前端、私钥留后端这条线
这是个不需要讨论的原则:私钥绝对不能出现在前端代码里。前端构建产物本质上是公开的,打包、压缩、混淆都只是提高阅读难度,不构成保护。把私钥写进前端,等于把保险箱和钥匙一起交给用户。
对应地,前端的 RSA 只能做两件事:
- 用公钥加密(把对称密钥或短数据发出去)。
- 用公钥验签(校验服务端返回的数据没被篡改)。
需要签名的地方一律在服务端做。如果有人跟你说“前端用私钥签名然后后端用公钥验”,这套方案的私钥已经泄露了,签名本身没有任何意义,实际效果和明文加个固定字符串差不多。
另外,公钥本身要保证是真公钥。如果公钥是后端接口下发的,那就要考虑这个接口本身是否可信——在 TLS 覆盖的前提下这是没问题的,但如果链路本身没有保护,中间人连公钥一起换掉,后面所有加密都白搭。这就是为什么第 1 节里说,传输层的问题必须归传输层解决。
6. 把四件工具串成一条完整链路
到这里四个工具各自的定位都清楚了,实际项目里它们基本不会单独出现,而是被串成一条链:AES 干重活,RSA 送钥匙,MD5 做校验,Base64 负责让二进制能上文本通道。
6.1 混合加密:随机 AES 密钥 + RSA 包裹
这套方案的逻辑不复杂,用一句话概括:临时生一把 AES 密钥用来加密数据,再用 RSA 公钥把这把 AES 密钥加密,两样一起发给后端;后端先用 RSA 私钥解出 AES 密钥,再用它解数据。
为什么这么设计,理由很直接:
- AES 是对称算法,加解密速度快,适合处理任意长度的数据。
- RSA 是非对称算法,能解决“密钥怎么安全送过去”的问题,但它慢,而且有长度上限。
- 两者结合,各取所长。
async function hybridEncrypt(plainText, rsaEncryptor) { // 1. 随机生成 32 字节 AES-256 密钥(用密码学安全的随机源) const aesKeyBytes = crypto.getRandomValues(new Uint8Array(32)); const aesKey = CryptoJS.lib.WordArray.create(aesKeyBytes); const iv = CryptoJS.lib.WordArray.random(16); // 2. AES 加密正文 const cipherParams = CryptoJS.AES.encrypt( CryptoJS.enc.Utf8.parse(plainText), aesKey, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); const data = cipherParams.toString(); // 密文 Base64 const ivB64 = CryptoJS.enc.Base64.stringify(iv); // IV Base64 // 3. RSA 加密那把 AES 密钥(32 字节,远小于 245 字节上限,安全) const keyB64 = CryptoJS.enc.Base64.stringify(aesKey); const encryptedKey = rsaEncryptor.encrypt(keyB64); if (encryptedKey === false) throw new Error('RSA 加密 AES 密钥失败'); return { data, iv: ivB64, key: encryptedKey }; }这里有三个点值得单独拎出来说。
第一,随机源必须用crypto.getRandomValues,不能用Math.random()。后者是伪随机,种子可预测,用它生成的密钥等于没有密钥。这个错误在网上的示例代码里出现的频率高得吓人。
第二,AES 密钥本身要先 Base64 再交给 RSA,因为jsencrypt处理的是字符串。后端的顺序就反过来:解 Base64 → 得到 32 字节 → 当 AES 密钥用。
第三,批次之间不要复用密钥。每次请求都新生成一把,成本可以忽略(一次随机数生成而已),但安全性提升明显。
6.2 时间戳、随机串与摘要顺序
加密解决了“看不到”,但没解决“重放”。同一份密文被截获后原样再发一次,后端一样会认为是合法请求。所以实际链路里还得加时间戳和随机串:
| 字段 | 作用 | 注意事项 |
|---|---|---|
timestamp | 限定请求有效期(如 5 分钟) | 前后端时钟要大致同步,服务端要做容差 |
nonce | 一次性随机串,防重放 | 服务端需要缓存已用过的 nonce 并设过期时间 |
sign | 参数完整性校验 | 签名参与字段的顺序必须双方一致 |
签名的计算顺序是个高频踩坑点。参数排序、拼接分隔符、是否包含空值字段、要不要把timestamp和nonce也算进去——每一条都必须白纸黑字写进文档。
我习惯的做法是把参与签名的字段按字典序排好,用key=value&key=value的形式拼接,末尾再拼上密钥做 HMAC。这套规则简单、可复述、出错时也容易对齐。但要特别注意值是空字符串和字段不存在这两种情况要区分对待,很多签名对不上的问题都出在这里:前端用了Object.keys遍历,把值为undefined的字段也纳入拼接,后端则直接跳过了。
还有一点,签名的密钥和前端的加密密钥是两回事。签名密钥如果放在前端,那它也只是一个“约定值”,防的是参数在传输过程中被无意改变,防不住有意的伪造。真正需要防伪造的签名,密钥必须在服务端,前端只负责把参数原样发过去。
7. 联调阶段最常见的几类报错与排查顺序
理论讲完,落到真实场景。下面这三类问题覆盖了我遇到过的绝大部分情况,排查顺序建议从上往下,因为前一个问题常常会伪装成后一个。
7.1 解出来前几位是乱码,后面正常
这个现象非常典型,几乎可以锁定为IV 错位。CBC 模式下第一个分组的解密依赖 IV,IV 错了只有第一块乱,后面因为用的是前一块密文,会正常解出来。
排查动作只有两个:确认 IV 是不是每次随机生成的,确认 IV 的传输位置和长度双方理解一致。我遇到最多的情况是“前端把 IV 拼在密文前面,后端不知道要先切出前 16 字节”。
如果整段都是乱码,那就不是 IV 的问题,而是密钥不对或者算法参数不一致(模式、填充、编码任意一项不同都会这样)。
7.2 提示编码非法或长度不对
先看长度。AES 密文的字节数一定是 16 的整数倍(PKCS#7 会补齐),Base64 之后长度是 4 的倍数。如果长度对不上,说明中间有一层转换错了。
然后看字符集。报InvalidCharacterError或类似错误时,八成是把含中文的字符串直接喂给了btoa。报“Unexpected token”或“不是有效的 Base64”时,八成分两种情况:URL 里的+变成了空格,或者末尾的=被截掉了。
还有一个比较隐蔽的情况:Base64 解码本身非常宽容,多一个字符、少一个字符可能都能解出来,只是解出来的字节是错的。所以不要指望解码这一层帮你发现问题,要在解码之后校验长度或者结构。
7.3 同一明文两次密文不同,是不是写错了
不是。只要 IV 是随机的,CBC 模式下同一明文两次加密的密文就一定不同。这正是它和 ECB 的关键区别,也是安全性的体现。如果两次密文一样,反而说明 IV 被固定了,是个需要修的问题。
RSA 的情况稍微复杂一点:PKCS#1 v1.5 填充里也包含随机字节,所以同一个明文两次 RSA 加密结果也不同。但如果用的是jsencrypt并且观察到结果完全一致,那就得检查是不是把加密结果缓存了。
至于 MD5,同样的输入一定产生同样的输出,这一点没有例外。如果你观察到两次 MD5 不一样,那输入一定不一样——可能是多了一个空格,可能是换行符(\n和\r\n的区别),也可能是对象转 JSON 时键的顺序变了。我吃过一次亏,前端用JSON.stringify转对象算摘要,结果两个属性顺序不同的等价对象算出了不同的值,排查了很久。解决办法是先把键排好序再序列化。
最后分享一个我自己的习惯:在开发环境里把加密链路的每一步都打上日志,包括原始字节长度、每一步输出的前 16 个字符、以及最终的完整密文。上线前把这些日志用环境变量关掉。这套日志在联调时的作用巨大——两边各贴一段出来,对比到第三步就能定位问题出在哪一环,比来回问“你是不是用 UTF-8 了”高效得多。