彻底搞懂公钥加密的核心原理与实际应用
这次想说点实在的:前端做公钥加密,到底怎么选?
说实话,我一开始也没打算在前端搞什么公钥加密。毕竟大家都说“前端代码都能看,加密有啥用”,这话没错。但问题是,现在有些业务场景就是得在客户端先处理一点敏感数据,比如用户填写完表单后要提前加密某些字段再发给后端;或者要做本地存储加密,防止明文被直接读出来。这时候,哪怕不能完全防住逆向,至少也得提高门槛。
所以我最近折腾了几种前端可用的公钥加密方案:Web Crypto API、Node.js crypto 模块(配合打包工具用在前端)、还有第三方库如 jsencrypt 和 forge。结论先放这儿:我现在基本只用 Web Crypto + forge 配合着来,其他的基本都踩过坑,要么太老,要么兼容性差到怀疑人生。
谁更灵活?谁更省事?
先说体验最差的:jsencrypt。这库曾经很火,基于 RSA 加密,封装得还挺简洁。但我现在是真不敢用了——它底层依赖的是已废弃的 jsbn 库,没类型定义,ES6 导入麻烦,最关键的是,不支持现代哈希算法填充(比如 RSA-PSS),而且对密钥格式要求死板,必须 PEM 格式还得带头尾标记。有一次我传了个标准 base64 的 public key,解码失败,折腾了半天发现是因为少了 -----BEGIN PUBLIC KEY----- 这种字符串……服了。
// jsencrypt 用法示例(我现在看到这种就想绕路)
import JSEncrypt from 'jsencrypt';
const encrypt = new JSEncrypt();
encrypt.setPublicKey(-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----);
const encrypted = encrypt.encrypt('hello world');
不是说它不能用,而是这种写法在现代项目里太格格不入了。尤其你要是用 Vite 或者 Webpack 5,Tree-shaking 可能还抽不掉冗余代码,体积也不小。我已经把它从所有新项目里清出去了。
Web Crypto API:原生才是王道
后来我转头去认真看了下浏览器自带的 Web Crypto API。说实话,第一次用的时候觉得文档反人类,API 设计像上个世纪的产物。但它最大的优势是:无需引入任何依赖,现代浏览器基本都支持(除了 IE 全家桶)。关键是安全模型更可信——毕竟是内置的,不会被篡改。
而且它支持的算法比你想得多:RSA-OAEP、ECDSA、AES-GCM 等等都有。虽然接口是 Promise-based 并且需要手动 import/export 密钥,但一旦熟悉套路,其实挺顺手的。
// 使用 Web Crypto API 进行 RSA-OAEP 加密
async function encryptWithPublicKey(data, publicKeyPem) {
// 将 PEM 格式的公钥转换为 ArrayBuffer
const binaryString = atob(publicKeyPem.replace(/-----.*-----/g, '').replace(/s/g, ''));
const publicKeyRaw = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
publicKeyRaw[i] = binaryString.charCodeAt(i);
}
const importedKey = await crypto.subtle.importKey(
'spki',
publicKeyRaw,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
);
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
const encrypted = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
importedKey,
encodedData
);
return btoa(String.fromCharCode(...new Uint8Array(encrypted)));
}
注意这里的坑:Web Crypto 要求密钥是 ArrayBuffer 形式,所以你得自己把 PEM 解码成 DER 再喂进去。这个过程容易出错,尤其是换行符处理和 base64 解码方式。我当时就是因为用了错误的 atob 解码方式,在 Safari 上直接报错。后来改成用 TextDecoder 才稳下来。
还有一个限制:Web Crypto 的非对称加密只支持小数据块(RSA 通常是 100 多字节),所以大内容得用混合加密——用 RSA 加密一个随机 AES 密钥,再用 AES 加密正文。这套流程得你自己实现,没有一键封装。
Forge:妥协中的实用派
那有没有既能用现代构建工具,又不用自己写一大堆底层逻辑的?我最后找到了 node-forge。别看名字带 node,它其实可以在浏览器跑,通过 webpack 或 browserify 打包进来就行。
forge 的好处是:支持完整的 X.509、PKCS#8、PEM 解析,可以直接读字符串形式的密钥,而且提供了 high-level 接口。比如你可以直接 pki.publicKeyFromPem(),爽得很。
// 使用 forge 做 RSA 加密
import forge from 'node-forge';
function encryptWithForge(data, publicKeyPem) {
const publicKey = forge.pki.publicKeyFromPem(publicKeyPem);
const encrypted = publicKey.encrypt(data, 'RSA-OAEP', {
md: forge.md.sha256.create(),
});
return forge.util.encode64(encrypted);
}
// 解密示例(一般在服务端)
function decryptWithPrivateKey(encryptedB64, privateKeyPem) {
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
const encrypted = forge.util.decode64(encryptedB64);
return privateKey.decrypt(encrypted, 'RSA-OAEP', {
md: forge.md.sha256.create(),
});
}
我现在的做法是:前端用 Web Crypto 处理简单的加密需求(比如 token 签名验证),复杂或需要兼容旧系统的就上 forge。虽然它体积大了点(gzip 后大概 80KB),但在很多企业级项目里这点代价是可以接受的。
特别提醒一点:如果你要用 fetch 请求获取公钥,记得确认响应的是纯文本而不是 JSON,不然解析 PEM 容易多一层转义。我就因为后端返回了个 JSON 包裹的 key 字段,结果忘了 JSON.parse 再取值,debug 了半小时才意识到问题。
我的选型逻辑
总结一下我的选择路径:
- 如果只是轻量级加密、签名验证,且目标浏览器支持良好 → 优先选 Web Crypto API
- 如果有复杂证书操作、密钥格式混乱、需要调试方便 → 上 forge
- jsencrypt?除非维护老项目,否则建议早点替换掉
- Node.js crypto 模块?别想了,打包进前端会炸体积,还可能触发 SSR 兼容问题
还有一个隐藏因素:团队协作。如果你的团队新人多,Web Crypto 学习成本高,那我反而会推荐先用 forge,文档全、例子多,至少不至于让同事卡在 importKey 报错上一整天。
另外提一嘴性能。我在 Chrome 120 上测试过,同样加密 1KB 数据,Web Crypto 平均耗时 2ms,forge 是 6~8ms。差距不大,但如果高频调用就得考虑了。不过实际业务中这种操作通常不会密集发生,所以我觉得可以忽略。
最后一点真实感受
前端做公钥加密这件事,本质上是在“有限的安全”和“可用性”之间找平衡。你永远防不住有心人调试内存、hook 函数,但至少能让自动化爬虫和初级攻击者知难而退。
我也试过 wasm 版的加密库,比如把 Rust 编译成 wasm 来跑 openssl 绑定,理论上更快更安全,但调试起来简直是噩梦。改个密钥格式要重新编译,开发效率断崖下跌,最终放弃。
所以目前最优解还是 Web Crypto + forge 搭配使用。哪个顺手用哪个,别追求完美方案。改完后仍有一两个小问题?只要不影响主流程,我就先上线再说。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,后续会继续分享这类实战经验。有更优的实现方式欢迎评论区交流。
