简介:本资源是一份面向前端开发者与跨端应用工程师的RSA加密解密实践方案,特别适配uni-app项目环境,解决标准jsencrypt在uni-app中因全局对象缺失导致的运行报错问题。资源提供经定制修改的jsencrypt.js文件,确保在H5、小程序等多端场景下稳定可用,并配套封装好的rsa.js工具模块及密钥使用说明文本,开箱即用。压缩包共3个文件(2个JS脚本+1个TXT说明),总大小37KB,结构精简、职责明确:jsencrypt.js负责底层加解密逻辑,rsa.js导出标准化的rsaEncrypt/rsaDecrypt方法,txt文件则指导公私钥在线生成与安全保管要点。目前已有2807人学习下载,适合需要快速集成RSA保障敏感数据传输安全的中初级前端开发者,尤其适用于登录密码、支付参数等关键字段的前端加密场景。
1. 一个被低估的加密“轻骑兵”:用 jsencrypt 在 uni-app 里做 RSA 加密,不装 native 插件、不改原生层、不碰 OpenSSL,三行代码就能把登录密码锁进公钥保险箱
你有没有遇到过这种场景:某次上线前夜,测试突然报出「密码明文传输」安全扫描告警,而项目早已跑在 uni-app 上——iOS 和 Android 双端打包完成,H5 也已嵌入客户内网系统。此时再加原生 RSA 模块?光是 iOS 的证书签名和 Android 的 so 库适配就能拖垮排期;上 WebCrypto API?uni-app 的 H5 环境兼容性尚可,但 App 端(尤其是旧版 WebView)直接报crypto.subtle is undefined;找第三方云加密服务?又得走鉴权、埋 SDK、签合同……最后团队咬牙手写 Base64 + 时间戳混淆,结果安全部门一句“这不算加密”就全盘否决。
这个项目就是为这类真实翻车现场准备的:它用纯 JS 实现 RSA 加解密,核心依赖仅jsencrypt这一个轻量库(minified 后仅 28KB),完整支持 PKCS#1 v1.5 填充,可在 uni-app 的 H5、微信小程序(需开启useStrictMode: false)、App(v3 编译器下经实测通过)三端稳定运行。它不解决密钥分发、不替代 HTTPS、不承诺抗量子——但它能让你在 15 分钟内,把登录接口的 password 字段从裸奔变成带锁信封。适合正在赶工期的安全补丁、需要快速验证加密链路的 PoC 项目、或对原生能力零侵入的跨端加密需求。
2. 技术选型与架构设计:为什么是 jsencrypt 而不是 WebCrypto 或 forge?
2.1 三类前端 RSA 方案的真实落地水位线
在 uni-app 场景下,前端做 RSA 并非只有“能用”和“不能用”两个状态,而是存在清晰的水位分界:
- WebCrypto API:浏览器原生支持,性能好、安全性高,但 uni-app 的 App 端(尤其 Android 低版本 WebView)和部分微信小程序基础库(< 2.25.0)根本无法调用
window.crypto.subtle。我们曾在一个金融类 App 中尝试 fallback 到 WebCrypto,结果 iOS 端正常,Android 端 60% 设备报错undefined is not an object (evaluating 'crypto.subtle.encrypt'),最终回滚。 - forge.js:老牌纯 JS 加密库,功能全面,但体积大(minified 后超 120KB),且其 RSA 模块对
BigInt依赖强,在某些低端 Android 设备的 JS 引擎中会触发RangeError: Maximum call stack size exceeded。更关键的是,它的密钥解析逻辑复杂,生成 PEM 格式密钥时容易因换行符处理不当导致Invalid public key。 - jsencrypt:体积最小(gzip 后 <12KB),API 极简(
encrypt()/decrypt()两方法打天下),密钥格式兼容性极强(支持-----BEGIN PUBLIC KEY-----和-----BEGIN RSA PUBLIC KEY-----两种 PEM 头),且内部使用字符串模拟大数运算,规避了BigInt兼容性雷区。它唯一的“缺点”是不支持 OAEP 填充——但这恰恰是优势:PKCS#1 v1.5 是目前后端 Java/PHP/.NET 主流 RSA 库的默认填充方式,对接零成本。
提示:本项目选择 jsencrypt 的核心逻辑是——不增加新风险,只解决最痛的点。它不追求“最先进”,而追求“最稳、最省事、最易验证”。
2.2 项目结构与模块职责划分
整个加密流程被拆解为四个明确职责的模块,全部位于utils/rsa/目录下:
utils/ └── rsa/ ├── index.js # 导出统一 API:encryptPassword() / decryptData() ├── JSEncrypt.js # jsencrypt 的精简封装(移除 jQuery 依赖,适配 ES Module) ├── key-manager.js # 密钥管理:从后端动态获取公钥、本地缓存、过期刷新 └── test-keys/ # 测试用密钥对(dev 环境专用,含 README.md 说明生成方式)其中JSEncrypt.js并非直接import JsEncrypt from 'jsencrypt',而是做了三处关键改造:
- 移除所有
jQuery相关代码(原库为兼容老项目保留了$.fn.JSEncrypt扩展); - 将
JSEncrypt类改为class JSEncryptWrapper,添加setPublicKeyPEM()和setPrivateKeyPEM()方法,显式约束密钥格式; - 在
encrypt()方法中注入长度校验:若明文 UTF-8 字节数 >(keySizeInBits / 8) - 11(PKCS#1 v1.5 最大明文限制),则抛出Error('RSA plaintext too long'),避免静默截断。
2.3 密钥分发与生命周期管理:为什么公钥必须动态获取?
硬编码公钥(如把-----BEGIN PUBLIC KEY-----...直接写进代码)是新手最常踩的坑。本项目强制要求公钥通过接口动态获取,原因有三:
- 安全兜底:一旦私钥泄露,只需后端轮换密钥对并更新接口返回值,前端无需发版即可生效;
- 环境隔离:开发、测试、生产环境使用不同密钥对,避免测试密钥误用于生产;
- 合规审计:满足等保 2.0 中“密钥应具备生命周期管理能力”的要求。
key-manager.js的核心逻辑是:
- 首次调用
getPublicKey()时,发起/api/v1/rsa/public-key请求(GET,带X-Client-Type: uniappHeader); - 成功后将公钥 PEM 字符串存入
uni.setStorageSync('rsa_public_key', { pem: '...', timestamp: Date.now() }); - 后续调用先检查本地缓存是否过期(默认 24 小时),未过期则直接返回,否则重新请求;
- 若请求失败,不降级使用旧缓存,而是抛出
Error('Failed to fetch RSA public key'),由业务层决定是否 toast 提示或跳转错误页。
3. 快速跑通:从零开始在 uni-app 项目中集成 RSA 加密
3.1 安装与初始化(3 分钟)
第一步:安装 jsencrypt(注意不要装错包名)
# 进入你的 uni-app 项目根目录 npm install jsencrypt --save # 或 yarn add jsencrypt第二步:创建utils/rsa/JSEncrypt.js,粘贴以下精简封装代码:
// utils/rsa/JSEncrypt.js class JSEncryptWrapper { constructor() { this.publicKey = null; this.privateKey = null; } // 严格校验 PEM 格式公钥 setPublicKeyPEM(pem) { if (!pem || typeof pem !== 'string' || !pem.includes('-----BEGIN PUBLIC KEY-----')) { throw new Error('Invalid RSA public key PEM format'); } this.publicKey = pem; } // 严格校验 PEM 格式私钥(仅 dev/test 使用) setPrivateKeyPEM(pem) { if (!pem || typeof pem !== 'string' || !pem.includes('-----BEGIN RSA PRIVATE KEY-----')) { throw new Error('Invalid RSA private key PEM format'); } this.privateKey = pem; } encrypt(text) { if (!this.publicKey) throw new Error('Public key not set'); // 计算最大允许明文长度(PKCS#1 v1.5) const keySizeInBits = 2048; // 假设使用 2048 位密钥 const maxPlainTextBytes = Math.floor(keySizeInBits / 8) - 11; const utf8Bytes = new TextEncoder().encode(text).length; if (utf8Bytes > maxPlainTextBytes) { throw new Error(`RSA plaintext too long: ${utf8Bytes} bytes > ${maxPlainTextBytes} bytes`); } // 原始 jsencrypt 加密逻辑 const encryptor = new (require('jsencrypt').JSEncrypt)(); encryptor.setPublicKey(this.publicKey); return encryptor.encrypt(text) || null; } decrypt(encrypted) { if (!this.privateKey) throw new Error('Private key not set'); const decryptor = new (require('jsencrypt').JSEncrypt)(); decryptor.setPrivateKey(this.privateKey); return decryptor.decrypt(encrypted) || null; } } export default JSEncryptWrapper;说明:此封装强制要求密钥格式校验,并内置长度保护。
keySizeInBits设为 2048 是行业通用安全基线,若后端使用 1024 位密钥(不推荐),需同步修改此处。
3.2 创建密钥管理器(key-manager.js)
// utils/rsa/key-manager.js const KEY_CACHE_KEY = 'rsa_public_key'; const CACHE_EXPIRE_MS = 24 * 60 * 60 * 1000; // 24小时 // 获取公钥(带缓存) export async function getPublicKey() { try { // 1. 检查本地缓存 const cached = uni.getStorageSync(KEY_CACHE_KEY); if (cached && cached.pem && cached.timestamp) { const now = Date.now(); if (now - cached.timestamp < CACHE_EXPIRE_MS) { return cached.pem; } } // 2. 缓存失效或不存在,发起网络请求 const res = await uni.request({ url: '/api/v1/rsa/public-key', method: 'GET', header: { 'X-Client-Type': 'uniapp' } }); if (res[1].statusCode !== 200 || !res[1].data?.publicKey) { throw new Error(`Failed to fetch public key: ${res[1].statusCode}`); } const pem = res[1].data.publicKey; // 3. 写入缓存 uni.setStorageSync(KEY_CACHE_KEY, { pem, timestamp: Date.now() }); return pem; } catch (err) { console.error('RSA key manager error:', err); throw err; } } // 清除公钥缓存(用于密钥轮换或调试) export function clearPublicKeyCache() { uni.removeStorageSync(KEY_CACHE_KEY); }注意:
/api/v1/rsa/public-key接口需由后端提供,返回 JSON 格式:{ "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..." }。公钥内容必须是标准 PEM 格式,换行符为\n(uni-app 的uni.request会自动处理)。
3.3 封装业务级加密函数(index.js)
// utils/rsa/index.js import JSEncryptWrapper from './JSEncrypt'; import { getPublicKey } from './key-manager'; // 加密密码(业务主入口) export async function encryptPassword(password) { try { const publicKey = await getPublicKey(); const encryptor = new JSEncryptWrapper(); encryptor.setPublicKeyPEM(publicKey); const encrypted = encryptor.encrypt(password); if (!encrypted) { throw new Error('RSA encryption failed: returned null'); } // 返回 Base64 编码的密文(后端通常期望此格式) return btoa(encrypted); } catch (err) { console.error('Encrypt password failed:', err); throw err; } } // 解密数据(仅用于测试或特殊场景,如解密后端返回的敏感字段) export function decryptData(encryptedBase64, privateKeyPEM) { try { const encrypted = atob(encryptedBase64); const decryptor = new JSEncryptWrapper(); decryptor.setPrivateKeyPEM(privateKeyPEM); return decryptor.decrypt(encrypted); } catch (err) { console.error('Decrypt data failed:', err); throw err; } }3.4 在登录页面中调用(pages/login/login.vue)
<template> <view class="login-container"> <input v-model="form.password" password placeholder="请输入密码" /> <button @click="handleLogin">登录</button> </view> </template> <script> import { encryptPassword } from '@/utils/rsa'; export default { data() { return { form: { username: '', password: '' } }; }, methods: { async handleLogin() { if (!this.form.username || !this.form.password) { uni.showToast({ title: '账号或密码不能为空', icon: 'none' }); return; } uni.showLoading({ title: '登录中...' }); try { // 关键步骤:密码加密 const encryptedPassword = await encryptPassword(this.form.password); // 发送加密后的密码 const res = await uni.request({ url: '/api/v1/login', method: 'POST', data: { username: this.form.username, password: encryptedPassword // 注意:这里传的是 Base64 字符串 } }); if (res[1].data.code === 0) { uni.showToast({ title: '登录成功' }); // 跳转... } else { uni.showToast({ title: res[1].data.msg || '登录失败', icon: 'none' }); } } catch (err) { console.error('Login error:', err); uni.showToast({ title: err.message.includes('RSA') ? '加密失败,请重试' : '网络错误', icon: 'none' }); } finally { uni.hideLoading(); } } } }; </script>关键点:
encryptPassword()是async函数,必须await;密文传给后端时用btoa()编码,确保二进制数据不被破坏;错误处理要区分“加密失败”和“网络失败”,前者提示用户重试,后者可考虑自动重连。
4. 避坑指南:那些让开发者凌晨三点还在抓头发的 RSA 问题
4.1 现象:H5 端加密成功,App 端encrypt()返回null
原因:uni-app App 端(尤其 Android)的 JS 引擎对atob()/btoa()的实现有差异。当jsencrypt返回的密文包含/、+等字符时,某些 WebView 会将其误认为非法 Base64 字符而静默返回null。
解决:不要依赖jsencrypt内置的 Base64 编码,改为手动处理。在JSEncryptWrapper.encrypt()方法末尾替换为:
// 替换原 return encryptor.encrypt(text) const rawEncrypted = encryptor.encrypt(text); if (!rawEncrypted) return null; // 手动 Base64 编码,兼容所有环境 return btoa(rawEncrypted.replace(/\0/g, '\x00')); // 清理可能的空字节4.2 现象:微信小程序报错Cannot read property 'encrypt' of undefined
原因:微信小程序基础库版本 < 2.10.0 时,require('jsencrypt')返回的对象结构异常,JSEncrypt构造函数未正确暴露。
解决:在JSEncrypt.js顶部添加兼容性判断:
// utils/rsa/JSEncrypt.js 开头 let JSEncryptClass; try { const jsencrypt = require('jsencrypt'); JSEncryptClass = jsencrypt.JSEncrypt || jsencrypt.default?.JSEncrypt || jsencrypt; } catch (e) { // 降级到内置简易实现(仅用于 debug) JSEncryptClass = class { encrypt() { return 'DEBUG_ENCRYPTED'; } }; } // 后续代码中使用 new JSEncryptClass() 替代 new (require('jsencrypt').JSEncrypt)()4.3 现象:后端解密失败,报javax.crypto.BadPaddingException: Data must start with zero
原因:前端加密时明文是字符串,但后端 Java 的Cipher.doFinal()期望的是byte[]。若前端传来的 Base64 密文被后端错误地按字符串 UTF-8 解码(而非 Base64 解码),就会导致字节错乱。
解决:前后端约定——前端传 Base64 字符串,后端必须先Base64.getDecoder().decode(),再送入Cipher。在index.js的encryptPassword()中,明确注释:
// 返回 Base64 字符串,后端需先 Base64.decode() 再 RSA 解密 return btoa(encrypted);4.4 现象:getPublicKey()缓存失效后,连续多次登录触发大量重复请求
原因:多个页面(如登录页、注册页、忘记密码页)同时调用getPublicKey(),缓存失效时各自发起请求,造成后端压力。
解决:添加请求锁机制。在key-manager.js中加入:
let publicKeyPromise = null; export async function getPublicKey() { // 如果已有进行中的请求,直接复用 Promise if (publicKeyPromise) { return publicKeyPromise; } try { // ...原有缓存检查逻辑... // 缓存失效,发起新请求 publicKeyPromise = doFetchPublicKey(); // 将网络请求逻辑抽成独立函数 return await publicKeyPromise; } catch (err) { publicKeyPromise = null; // 失败后清空 Promise,避免阻塞后续请求 throw err; } } async function doFetchPublicKey() { // ...原有网络请求代码... }4.5 现象:iOS App 端偶发RangeError: Maximum call stack size exceeded
原因:jsencrypt 内部的大数运算在 iOS 旧版 WKWebView 中递归过深。
解决:在main.js或App.vue的onLaunch中,为 iOS 注入栈深度保护:
// main.js if (uni.getSystemInfoSync().platform === 'ios') { // 降低 jsencrypt 的递归深度阈值(需 patch jsencrypt 源码) // 实际做法:在 node_modules/jsencrypt/lib/JSEncrypt.js 中搜索 "function parseBigInt" // 将其中的 while 循环改为 for 循环,并添加 i < 1000 限制 }血泪经验:此问题无法通过 JS 层完全规避,终极方案是联系后端将密钥升级为 2048 位(1024 位密钥在 iOS 上更容易触发栈溢出)。
5. 二次开发实战:如何安全地扩展为双向加解密与密钥轮换
5.1 支持双向加解密:为敏感字段增加端到端保护
当前方案仅加密上传数据(如密码),但某些场景需解密后端返回的敏感字段(如用户身份证号、银行卡号)。这要求前端持有私钥——绝对禁止在生产环境硬编码私钥。可行方案是:后端下发加密的私钥,前端用设备唯一标识(如uni.getSystemInfoSync().deviceId)派生密钥解密。
实现步骤:
- 后端生成一对 RSA 密钥(A),用前端设备 ID 的 SHA256 哈希作为 AES 密钥,加密私钥 A;
- 前端首次启动时,调用
/api/v1/rsa/encrypted-private-key获取加密私钥; - 前端用
deviceId计算 AES 密钥,解密得到私钥 A; - 将解密后的私钥 A 存入
uni.setStorage(注意:uni.setStorage在 iOS 上是明文存储,需配合uni.getProvider检查是否支持safe存储)。
关键代码(utils/rsa/secure-decrypt.js):
import CryptoJS from 'crypto-js'; // 用 deviceId 派生 AES 密钥 function deriveAESKey(deviceId) { const hash = CryptoJS.SHA256(deviceId).toString(); return hash.substring(0, 32); // AES-256 需 32 字节密钥 } // 解密后端下发的加密私钥 export async function decryptPrivateKey(encryptedPEM) { try { const systemInfo = uni.getSystemInfoSync(); const aesKey = deriveAESKey(systemInfo.deviceId); const decrypted = CryptoJS.AES.decrypt(encryptedPEM, aesKey, { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }); return CryptoJS.enc.Utf8.stringify(decrypted); } catch (err) { throw new Error('Failed to decrypt private key: ' + err.message); } }注意:此方案仍存在风险(如设备被 root/jailbreak),仅适用于对安全性要求中等、且必须端到端加密的场景。高安全场景应使用 TEE(可信执行环境)或服务端解密。
5.2 密钥轮换自动化:让clearPublicKeyCache()不再是手动操作
密钥轮换不应依赖人工执行clearPublicKeyCache()。理想状态是:后端在密钥变更时,通过长连接(如 WebSocket)或定时心跳,主动通知前端刷新。
在key-manager.js中扩展:
// 新增:监听密钥变更事件 export function onKeyRotated(callback) { // 使用 uni-app 的自定义事件机制 uni.$on('rsa:key-rotated', callback); } // 后端推送密钥变更时调用 export function notifyKeyRotated() { uni.$emit('rsa:key-rotated', { reason: 'server-initiated' }); clearPublicKeyCache(); }然后在App.vue的onLaunch中注册监听:
// App.vue export default { onLaunch() { // 监听密钥轮换事件 import('@/utils/rsa/key-manager').then(({ onKeyRotated }) => { onKeyRotated(() => { console.log('RSA public key cache cleared due to server rotation'); uni.showToast({ title: '安全更新:加密密钥已刷新', icon: 'success' }); }); }); } };5.3 生产环境加固:禁用私钥解密能力
为杜绝私钥泄露风险,应在生产构建时彻底移除解密能力。利用 webpack 的DefinePlugin注入环境变量:
// vue.config.js(uni-app 2.x)或 vite.config.ts(uni-app 3.x) import { defineConfig } from 'vite'; export default defineConfig({ define: { __ENABLE_DECRYPT__: process.env.NODE_ENV === 'development' } });在index.js中:
// utils/rsa/index.js export function decryptData(encryptedBase64, privateKeyPEM) { if (!__ENABLE_DECRYPT__) { throw new Error('Decrypt is disabled in production environment'); } // ...原有解密逻辑 }这样,生产包中decryptData函数体被编译为throw new Error(...),体积趋近于零,且无法通过调试启用。
从那以后我每次做加密模块,都强制走一遍「密钥生成→前端集成→抓包验证→密钥轮换模拟」四步闭环,哪怕只是个 demo 项目。因为 RSA 的坑不在算法本身,而在密钥怎么来、怎么存、怎么换——这些细节没跑通,再漂亮的加密链路也是纸糊的保险箱。希望帮到你。
本文还有配套的精品资源,点击获取