ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

uniapp中jsencrypt RSA加解密全链路避坑指南

uniapp中jsencrypt RSA加解密全链路避坑指南

简介:本资源是一份面向前端开发者与跨端应用工程师的RSA加密解密实践方案,特别适配uni-app等基于JavaScript的跨平台框架,解决项目中敏感数据(如登录凭证、支付信息)需前端加密传输的核心安全需求。资源包共3个文件,含2个关键JS脚本(经定制修改的jsencrypt.js与封装好的rsa.js)及1个说明文本,总大小仅37KB,轻量易集成;其中jsencrypt.js已适配uni-app运行时环境,规避了原版在Vue编译阶段报错的问题,rsa.js则统一导出rsaEncrypt/rsaDecrypt方法,大幅降低调用门槛。目前已有2805人学习下载,资源附带公私钥在线生成指引与完整使用路径说明,读者可直接复用加密逻辑、快速验证加解密流程,并掌握uni-app环境下第三方加密库的兼容性改造思路。

1. 前端 RSA 加密不是“配个公钥就完事”:jsencrypt 在 uniapp 中跑通加密解密链路的真实门槛

你是不是也试过在 uniapp 里用 jsencrypt,填了公钥、调了 encrypt(),结果后端死活解不开?或者更玄学的是——本地 H5 能通,真机调试却返回空字符串?这不是你代码写错了,而是前端做 RSA 加密时,密钥格式、填充方式、字符编码、跨平台 JS 运行时差异这四座大山,一座没翻过去,整条链路就断在 request body 之前。这个资源不是教你怎么npm install jsencrypt,而是把某跨平台系统中真实跑通的完整加解密闭环拆给你看:从服务端生成兼容前端的 PEM 公钥开始,到 uniapp 中处理中文、特殊符号、base64 换行、iOS/Android JSCore 差异,再到后端能原样解密的完整参数对齐。它适合正在联调登录注册、支付签名、敏感字段上报的 uniapp 开发者,尤其适合被“前端加密后端解不开”卡住超过半天的人——别再查 MDN 了,问题根本不在文档里,而在密钥生成那一刻的 openssl 参数选择上。


2. 密钥生成与格式转换:为什么你粘贴的公钥在 jsencrypt 里 parse 失败?

jsencrypt 对 PEM 格式极其敏感:它不接受带-----BEGIN RSA PUBLIC KEY-----的传统 PKCS#1 格式,只认-----BEGIN PUBLIC KEY-----的 PKCS#8;它会因换行符(\r\nvs\n)、多余空格、末尾换行缺失而静默失败;它甚至对 base64 编码块长度超 64 字符时是否换行都有强校验。很多开发者直接复制 OpenSSL 生成的公钥就往setPublicKey()里塞,结果encrypt()返回null却无任何报错——这是 jsencrypt 的黑匣子式设计缺陷,也是第一个必须亲手掰开的环节。

2.1 服务端生成兼容前端的 PKCS#8 公钥(Linux/macOS 终端实操)

# 第一步:生成 2048 位 RSA 私钥(务必用 -aes256 加密保护,生产环境不可省) openssl genrsa -aes256 -out private_key.pem 2048 # 第二步:从私钥导出 PKCS#8 格式公钥(关键!不是 rsa -pubout) openssl rsa -in private_key.pem -pubout -out public_key_pkcs8.pem # 第三步:清理公钥文件——删除所有空行、统一换行为 \n、确保首尾无空格 sed -i '' '/^$/d' public_key_pkcs8.pem # macOS sed(Linux 用 sed -i '/^$/d') sed -i '' 's/\r$//' public_key_pkcs8.pem # 清除 Windows 换行残留

提示:openssl rsa -pubout输出的是 PKCS#1(BEGIN RSA PUBLIC KEY),jsencrypt 会 parse 失败;必须用openssl rsa -pubout配合-outform PEM默认输出 PKCS#8(BEGIN PUBLIC KEY)。这是血泪经验——某导师曾因用错命令,在测试环境反复重装密钥三次才定位到根源。

2.2 将 PEM 公钥转为 jsencrypt 可直读的单行字符串

jsencrypt 的setPublicKey()接口实际接收的是字符串,但若你传入含换行的多行 PEM,它会在内部 base64 解码时因\n截断而失败。正确做法是预处理为无换行 base64 字符串,并手动补全 PEM 头尾:

// Node.js 环境下预处理(部署前执行一次,非运行时) const fs = require('fs'); const publicKeyPem = fs.readFileSync('./public_key_pkcs8.pem', 'utf8'); // 提取 base64 主体:去掉头尾行 + 合并所有行 const base64Body = publicKeyPem .split('\n') .filter(line => !line.trim().startsWith('-----') && line.trim().length > 0) .join(''); // 重新包裹为标准 PEM 字符串(注意:必须含换行!但传给 jsencrypt 时要去掉) const cleanPem = `-----BEGIN PUBLIC KEY-----\n${base64Body}\n-----END PUBLIC KEY-----`; console.log('cleanPem for jsencrypt:', cleanPem.replace(/\n/g, '')); // 输出单行字符串

参数说明:cleanPem.replace(/\n/g, '')输出形如-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...-----END PUBLIC KEY-----的单行字符串。此字符串可安全传入jsencrypt.setPublicKey()。注意:-----BEGIN PUBLIC KEY-----和-----END PUBLIC KEY-----必须保留,否则 jsencrypt 认不出格式。

2.3 uniapp 中加载公钥的两种可靠方式

方式一:静态资源内联(推荐用于固定公钥)

将上一步生成的单行字符串直接写入 JS 模块,避免网络请求引入不确定性:

// utils/rsa.js import JSEncrypt from 'jsencrypt'; export const getEncryptor = () => { const encryptor = new JSEncrypt(); // ✅ 正确:单行 PEM 字符串,无换行符 const PUB_KEY = '-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoY3...-----END PUBLIC KEY-----'; encryptor.setPublicKey(PUB_KEY); return encryptor; };
方式二:动态加载(需服务端配合返回 clean PEM)

若公钥需动态更新(如轮换机制),服务端 API 必须返回已清洗的单行 PEM 字符串,禁止返回原始多行 PEM:

// api/auth.js export const fetchPublicKey = () => { return $u.http.get('/api/v1/public-key').then(res => { // 后端已保证返回 clean PEM(单行,含头尾标记) const cleanPem = res.data.key; // e.g. "-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----" const encryptor = new JSEncrypt(); encryptor.setPublicKey(cleanPem); // ✅ 直接传入 return encryptor; }); };

逻辑说明:uniapp 的uni.request返回数据默认为 JSON,因此服务端需将 clean PEM 作为字符串字段返回(如{ "key": "-----BEGIN..." }),而非直接返回 PEM 文件流。若走文件下载,需用uni.downloadFile+uni.getFileSystemManager().readFile,但增加复杂度且易出错,静态内联或 API 返回字符串是更稳的选择。


3. jsencrypt 加密实战:处理中文、emoji、长文本与 uniapp 特有陷阱

前端加密不是encrypt('123456')就完事。真实业务场景中,你要加密的是用户手机号、身份证号、地址、甚至带 emoji 的昵称。jsencrypt 底层用的是 JavaScript 的btoa(),它只接受 Latin-1 编码字符串,对 UTF-16(JS 字符串默认)中的中文、emoji 会直接乱码。更致命的是,uniapp 的uni.getSystemInfoSync().platform返回ios/android/devtools,而 iOS 的 JavaScriptCore 对btoa()的实现与 V8 有细微差异,导致同一段中文在不同平台加密结果不一致——后端收到的密文永远解不开。

3.1 中文与 emoji 安全加密:UTF-8 编码预处理

jsencrypt 不处理编码,必须由你手动将 JS 字符串转为 UTF-8 byte array,再转为 Latin-1 字符串供btoa()使用:

// utils/utf8.js export const stringToUtf8Bytes = (str) => { const encoder = new TextEncoder(); // Web API,uniapp 支持 return encoder.encode(str); // Uint8Array }; export const utf8BytesToString = (bytes) => { const decoder = new TextDecoder(); return decoder.decode(bytes); }; // utils/rsa.js(续) export const encryptWithUtf8 = (text, encryptor) => { if (!text) return ''; // Step 1: 转 UTF-8 bytes const utf8Bytes = stringToUtf8Bytes(text); // Step 2: 将每个 byte 转为对应 Latin-1 字符(0-255 → charCode 0-255) let latin1Str = ''; for (let i = 0; i < utf8Bytes.length; i++) { latin1Str += String.fromCharCode(utf8Bytes[i]); } // Step 3: 交给 jsencrypt 加密 return encryptor.encrypt(latin1Str) || ''; }; // 使用示例 const encryptor = getEncryptor(); const encrypted = encryptWithUtf8('张三❤️138****1234', encryptor); console.log('Encrypted base64:', encrypted); // ✅ 可被后端标准 RSA 私钥解密

参数说明:TextEncoder.encode()将字符串按 UTF-8 编码为字节数组;String.fromCharCode(byte)将每个字节映射为 Latin-1 字符(byte 值 0-255 正好对应 charCode 0-255),这样btoa()才能正确编码。此方案在 iOS/Android/H5 全平台一致,彻底解决 emoji 乱码问题。

3.2 长文本分块加密:RSA 2048 的 245 字节硬限制

RSA 加密有严格长度限制:2048 位密钥最多加密 245 字节(PKCS#1 v1.5 填充)。若你直接传入 500 字符的地址,encrypt()会静默返回null。不能靠 try-catch 捕获,因为 jsencrypt 内部不抛错。必须提前切片:

// utils/rsa.js(续) export const encryptLongText = (text, encryptor, maxChunk = 245) => { if (!text) return ''; const utf8Bytes = stringToUtf8Bytes(text); const chunks = []; // 按 maxChunk 字节切分 for (let i = 0; i < utf8Bytes.length; i += maxChunk) { const chunk = utf8Bytes.slice(i, i + maxChunk); let latin1Str = ''; for (let j = 0; j < chunk.length; j++) { latin1Str += String.fromCharCode(chunk[j]); } const encryptedChunk = encryptor.encrypt(latin1Str); if (!encryptedChunk) { throw new Error(`RSA encryption failed at chunk ${i}, length ${chunk.length}`); } chunks.push(encryptedChunk); } // 用特殊分隔符连接(后端需按此规则解包) return chunks.join('|@|'); // 自定义分隔符,避开 base64 字符 }; // 使用示例:加密长地址 const longAddr = '北京市朝阳区建国路87号华贸中心3号写字楼12层1201室,附近有星巴克和地铁10号线'; const encryptedLong = encryptLongText(longAddr, encryptor); console.log('Encrypted long text:', encryptedLong);

逻辑说明:maxChunk = 245是 2048 位 RSA 的安全上限(256 - 11 = 245)。切片必须在 UTF-8 字节层面进行,而非字符串字符数——因为一个中文占 3 字节,一个 emoji 可能占 4 字节。|@|是自定义分隔符,后端解密时用split('|@|')拆分,再逐段用私钥解密后拼接 UTF-8 字节,最后TextDecoder.decode()还原字符串。

3.3 uniapp 真机调试的隐藏雷区:iOS WKWebView 的 btoa 兼容性

在 iOS 真机上,btoa()对某些字节序列(如连续多个 0x00)可能返回undefined,导致encrypt()返回null。这不是 jsencrypt 的 bug,而是 WKWebView 的实现缺陷。解决方案是加一层 fallback:

// utils/rsa.js(续) export const safeBtoa = (str) => { try { return btoa(str); } catch (e) { // WKWebView fallback:手动实现 base64 编码(仅针对 latin1 字符串) const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; let result = ''; for (let i = 0; i < str.length; i += 3) { const a = str.charCodeAt(i) || 0; const b = i + 1 < str.length ? str.charCodeAt(i + 1) : 0; const c = i + 2 < str.length ? str.charCodeAt(i + 2) : 0; const byte1 = a >> 2; const byte2 = ((a & 3) << 4) | (b >> 4); const byte3 = ((b & 15) << 2) | (c >> 6); const byte4 = c & 63; result += chars.charAt(byte1) + chars.charAt(byte2) + (i + 1 >= str.length ? '=' : chars.charAt(byte3)) + (i + 2 >= str.length ? '=' : chars.charAt(byte4)); } return result; } }; // 替换 jsencrypt 内部的 btoa(需 patch) // 注意:此操作需在 new JSEncrypt() 前执行,且仅影响当前实例 JSEncrypt.prototype.btoa = safeBtoa;

提示:此 patch 仅在检测到 iOS 平台时启用(uni.getSystemInfoSync().platform === 'ios'),避免影响其他平台性能。这是某跨平台系统在线上灰度时发现的偶发失败原因——日志显示encrypt()返回null,但控制台无报错,最终定位到 WKWebView 的btoa异常。


4. 常见问题排查:五个让开发者重启 IDE 三次的 jsencrypt 翻车现场

前端 RSA 加密失败,90% 的问题不在算法本身,而在环境、格式、编码这些“看不见的管道”。以下是某跨平台系统联调期间记录的真实踩坑清单,每一条都附带复现路径和验证方法。

4.1 现象:encrypt()返回null,控制台无任何错误

原因:公钥字符串含不可见字符(如 BOM、零宽空格、Windows\r\n混入)、或未包含-----BEGIN PUBLIC KEY-----头尾标记
解决:

  • 用 VS Code 打开公钥文件 →Ctrl+Shift+P→ 输入 “Toggle Render Whitespace” 显示所有空格/换行
  • 确认首行是-----BEGIN PUBLIC KEY-----(无空格无 BOM),末行是-----END PUBLIC KEY-----(无换行)
  • 在 JS 中打印PUB_KEY.length和PUB_KEY.charCodeAt(0),确认首字符是-(ASCII 45),非(BOM)

4.2 现象:H5 正常,iOS 真机返回空字符串,Android 正常

原因:iOS WKWebView 的btoa()对特定字节序列(如\0\0\0)返回undefined,jsencrypt 未做容错
解决:

  • 复现:加密字符串'\0\0\0abc'(三个 null 字节)
  • 验证:console.log(btoa('\0\0\0abc'))在 iOS 真机返回undefined
  • 方案:按 3.3 节实现safeBtoa并 patch jsencrypt

4.3 现象:后端解密后得到乱码(如޿),但 base64 解密正常

原因:前端未做 UTF-8 → Latin-1 转换,直接传入中文字符串,btoa()将 UTF-16 码点当 Latin-1 编码
解决:

  • 验证:console.log('张'.charCodeAt(0))→ 24352(UTF-16),但btoa()期望 0-255
  • 方案:必须用TextEncoder转 UTF-8 字节,再String.fromCharCode(byte)映射

4.4 现象:加密长文本时部分 chunk 返回null,但utf8Bytes.length显示未超 245

原因:切片在字符串层面而非字节层面进行,例如"👨‍💻"(emoji 组合)占 4 字节,若按字符切片会截断字节流
解决:

  • 用stringToUtf8Bytes(text).length获取真实字节数
  • 切片必须基于Uint8Array,而非text.substring()
  • 验证:console.log(new TextEncoder().encode('👨‍💻').length)→ 4

4.5 现象:uniapponLoad中调用加密,返回null;setTimeout(() => {}, 0)后调用正常

原因:jsencrypt 初始化依赖window对象,而 uniapp 的onLoad生命周期中window可能未完全就绪(尤其在分包加载时)
解决:

  • 避免在onLoad/onShow等生命周期钩子中立即使用new JSEncrypt()
  • 改为按需初始化:const encryptor = getEncryptor()封装在工具函数中,首次调用时创建实例
  • 或加简单检查:if (typeof window !== 'undefined') { new JSEncrypt() } else { console.warn('JSEncrypt not available in this context') }

5. 后端解密验证与联调技巧:用 OpenSSL 命令行秒级定位加解密断点

别等后端同学改完代码再测——你手头的终端就是最准的解密验证器。只要拿到服务端私钥和前端传来的 base64 密文,三行命令就能确认问题出在前端加密、网络传输、还是后端解密。这是某跨平台系统每次上线前必做的“后悔药”步骤。

5.1 从 base64 密文还原原始加密字节(验证前端输出)

前端encrypt()返回的是 base64 字符串,但 OpenSSL 解密需要二进制文件。先将其转为 DER 格式:

# 假设前端返回密文:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... echo "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..." | \ tr -d '\n' | \ base64 -d > encrypted.bin

参数说明:tr -d '\n'删除 base64 字符串中的换行(jsencrypt 输出可能含\n);base64 -d将 base64 解码为原始加密字节,存入encrypted.bin。此文件即 RSA 加密后的二进制数据,大小应为 256 字节(2048 位 / 8)。

5.2 用私钥解密并查看明文(定位是否前端加密错误)

# 使用服务端私钥解密(需输入私钥密码) openssl rsautl -decrypt -in encrypted.bin -inkey private_key.pem -passin pass:your_password > decrypted.txt # 查看解密结果(应为 UTF-8 文本) cat decrypted.txt | hexdump -C # 查看十六进制,确认是否 UTF-8 编码 # 若显示乱码,但 hexdump 显示 0xE4 0xB8 0xAD('中'的 UTF-8),说明解密成功,前端编码正确 # 若 hexdump 显示 0x00 0x00 0x00(大量零字节),说明前端未做 UTF-8 转换,传入了错误字符串

逻辑说明:openssl rsautl -decrypt是最底层的 RSA 解密命令,绕过所有后端框架封装。若此处解密失败,100% 是前端加密问题;若解密成功但内容乱码,则是前端未做 UTF-8 → Latin-1 转换;若解密成功且hexdump显示标准 UTF-8 字节(如中文为E4 B8 AD),则问题一定在后端代码(如用了错误的字符集解码)。

5.3 模拟前端加密流程(反向验证公钥是否可用)

当你怀疑公钥有问题,可完全模拟前端流程,用 OpenSSL 生成“应该被前端加密出”的密文,再与前端实际输出比对:

# 准备明文(UTF-8 编码的文本) echo -n "张三138****1234" > plain.txt # 用公钥加密(PKCS#1 v1.5 填充,与 jsencrypt 一致) openssl rsautl -encrypt -pubin -inkey public_key_pkcs8.pem -in plain.txt -out encrypted_by_openssl.bin # 转为 base64,与前端输出对比 base64 encrypted_by_openssl.bin | tr -d '\n' # 输出应与前端 jsencrypt.encrypt(...) 结果完全一致

提示:-pubin表示输入是公钥;-inkey指定公钥文件;-in plain.txt是明文文件(-n避免 echo 添加换行);-out输出二进制密文。此命令生成的密文,就是 jsencrypt 在相同公钥、相同明文下应该输出的结果。若不一致,说明公钥格式或填充方式不匹配。

5.4 uniapp 联调黄金 checklist(每次提测前必过)

检查项验证方法不通过表现修复动作
公钥格式head -n1 public_key.pem显示-----BEGIN RSA PUBLIC KEY-----重生成:openssl rsa -in priv.pem -pubout -out pub.pem
前端编码console.log(encryptor.encrypt('a'))返回null检查setPublicKey()传入的字符串是否含换行/BOM
中文处理加密'中'后用 OpenSSL 解密解出 ``确认使用stringToUtf8Bytes()+String.fromCharCode()
长文本切片加密 300 字符文本返回null或部分null检查切片是否基于Uint8Array.length,非text.length
iOS 兼容性真机运行btoa('\0\0\0')返回undefined启用safeBtoapatch

从那以后我每次交付 RSA 加密模块,都强制走一遍这个 checklist:先用 OpenSSL 解密前端密文确认内容,再用 OpenSSL 加密明文反向比对,最后在 iOS/Android/H5 三端各跑一次 emoji 加密。少一次,就可能多一次凌晨三点的线上故障排查。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进