资讯动态

前端RSA加密实战:分段加密解决长文本与中文乱码问题

发布时间:2026/10/17 6:55:51 来源:尧图企业网站定制
1. 前端加密到底在解决什么问题前端做加密这件事很多刚接触的人第一反应是“前端代码都是明文的加密有什么意义”。这个疑问很合理但忽略了一个关键场景数据在网络上传输的过程中经过的每一层节点都可能被截获。前端加密的核心目标不是防止用户自己看到数据而是让数据在离开浏览器到抵达服务端这段链路上以密文形态存在。RSA 是非对称加密算法里最经典的一种公钥加密、私钥解密。前端持有公钥把敏感字段加密后发给后端后端用私钥解开。这样即使请求被中间人截获拿到的也只是一串没有私钥就无法还原的密文。但实际用起来前端 RSA 有两个绕不开的坑加密内容长度受限和中文乱码。这两个问题几乎每个做过前端加密的人都踩过我自己在三个不同项目里反复遇到每次的解决思路都值得记录下来。这篇文章适合谁看如果你正在做登录密码加密、支付信息传输、表单敏感字段保护这类需求或者你已经用了某个 RSA 库但发现长文本加密报错、中文解密后变成乱码那这篇内容基本能覆盖你 90% 的问题场景。我会从原理讲到实操把长度限制的计算过程、中文编码的处理逻辑、分段加密的完整实现都拆开说清楚。2. RSA 加密的核心原理与前端选型2.1 为什么 RSA 会有长度限制RSA 的加密长度限制不是库的 bug而是算法本身的数学约束。RSA 加密的本质是模幂运算明文被当作一个大整数这个整数必须小于模数 n。模数 n 的位数就是密钥长度比如 1024 位、2048 位。但实际可用的明文长度比密钥长度还要小因为加密时要用到填充方案Padding。目前前端最常用的是 PKCS#1 v1.5 填充它至少占用 11 个字节。所以对于 2048 位的密钥密钥长度 2048 位 256 字节PKCS#1 填充占用 11 字节单次最大加密明文 256 - 11 245 字节如果是 1024 位密钥单次最大明文就是 128 - 11 117 字节。这个 245 字节的限制意味着什么一个中文字符在 UTF-8 编码下占 3 个字节245 字节大概只能装 81 个中文字。稍微长一点的地址、备注、JSON 片段就超了。这就是“字节过长”问题的根源。注意有些库在超长时会直接抛异常有些会静默截断后者更危险因为解密出来是残缺数据排查起来非常痛苦。2.2 前端 RSA 库怎么选前端可用的 RSA 库不算多主流的有几个方向方案特点适用场景jsencryptAPI 简单开箱即用短文本加密快速接入node-forge功能全支持分段需要精细控制的场景Web Crypto API浏览器原生性能好现代浏览器无需引入库JSEncrypt 改造版社区修复了部分问题需要兼容老项目jsencrypt 是最多人用的因为它 API 极简encrypt()和decrypt()两个方法就完事。但它默认不支持分段加密超长直接返回 false而且对中文的处理在某些版本里有问题。node-forge 更底层可以手动控制填充和分段适合需要精确处理的场景。Web Crypto API 是浏览器原生能力性能最好但 API 是异步的且对填充模式的支持有限制。我个人的选择逻辑是如果只是加密一个密码字段通常不超过 50 字节jsencrypt 足够如果要加密整个表单或长文本用 node-forge 做分段更稳妥。下面重点讲分段加密的完整实现因为这是解决长度问题的核心。3. 分段加密解决字节过长问题3.1 分段加密的核心思路分段加密的逻辑很直白把长明文按最大可加密长度切成若干块每块单独用 RSA 加密最后把密文块拼接起来。解密时反过来按密钥长度切分密文逐块解密再拼接。这里有个关键点密文块的长度是固定的等于密钥字节数。2048 位密钥的每个密文块就是 256 字节。所以解密时按 256 字节切分密文就能准确还原每一块。但分段不是随便切的。如果按字节硬切很可能把一个中文字符的 3 个字节切断导致解密后乱码。所以切分时必须考虑字符边界。3.2 按字符边界切分的实现先看一个错误的切法// 错误示范按字节硬切 function splitByBytes(str, maxBytes) { const encoder new TextEncoder(); const bytes encoder.encode(str); const chunks []; for (let i 0; i bytes.length; i maxBytes) { chunks.push(bytes.slice(i, i maxBytes)); } return chunks; }这段代码的问题在于bytes.slice可能把一个多字节字符切开。比如“中”是E4 B8 AD如果切在E4后面剩下的B8 AD就不是合法字符了。正确的做法是按字符逐个累加字节数超过阈值就切块function splitByCharBoundary(str, maxBytes) { const encoder new TextEncoder(); const chunks []; let currentChunk ; let currentBytes 0; for (const char of str) { const charBytes encoder.encode(char).length; if (currentBytes charBytes maxBytes) { chunks.push(currentChunk); currentChunk char; currentBytes charBytes; } else { currentChunk char; currentBytes charBytes; } } if (currentChunk) { chunks.push(currentChunk); } return chunks; }这个函数保证每个块都是完整的字符序列不会出现半个字符的情况。for...of遍历字符串时对于 BMP 之外的字符如某些 emoji也能正确处理因为它按码点迭代。3.3 分段加密与拼接的完整流程有了正确的切分函数加密流程就清晰了import JSEncrypt from jsencrypt; const PUBLIC_KEY -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY-----; function rsaEncryptLong(plainText, publicKey) { const encryptor new JSEncrypt(); encryptor.setPublicKey(publicKey); // 2048位密钥PKCS#1填充单块最大245字节 const MAX_CHUNK_BYTES 245; const chunks splitByCharBoundary(plainText, MAX_CHUNK_BYTES); const encryptedChunks chunks.map(chunk { const result encryptor.encrypt(chunk); if (result false) { throw new Error(加密失败检查公钥格式); } return result; }); // 用分隔符拼接方便后端切分 return encryptedChunks.join(::); }这里用::作为分隔符是因为 Base64 编码后的密文只包含A-Za-z0-9/不会出现冒号所以分隔符不会和密文冲突。后端收到后按::切分每段单独解密再拼接明文。实操心得分隔符不要用逗号或分号因为某些 Base64 变体可能包含这些字符。冒号、竖线、井号都是安全的选择。我习惯用::可读性好且不会冲突。3.4 密钥长度与分块大小的对应关系不同密钥长度对应的单块最大字节数不一样这个必须算准算错了要么浪费性能要么直接报错密钥长度模数字节填充占用单块最大明文1024 位12811117 字节2048 位25611245 字节4096 位51211501 字节现在新项目基本都用 2048 位起步1024 位已经被认为不够安全。4096 位安全性更高但性能开销明显前端加密场景下 2048 位是性价比最高的选择。如果你不确定后端用的哪种填充模式最稳妥的方式是问后端要一个测试密文前端解密试试。PKCS#1 v1.5 和 OAEP 的填充占用不同OAEP 至少占 42 字节单块最大明文会小很多。4. 中文乱码的根因与彻底解决4.1 中文乱码到底是怎么产生的中文乱码的本质是编码不一致。前端 JavaScript 字符串是 UTF-16 编码但加密库在把字符串转成字节时可能用了不同的编码方式。jsencrypt 内部用的是pkcs1pad2函数它逐字符取charCodeAt然后按单字节处理。对于 ASCII 字符没问题但中文字符的charCodeAt返回值大于 255直接截断就丢了信息。具体来说中文字符“中”的charCodeAt(0)是 20013二进制是0100111000101101超过 8 位。如果库只取低 8 位就变成了00101101也就是 45对应 ASCII 的-。解密出来自然就是乱码。4.2 解决方案一先编码再加密最直接的思路是加密前把字符串转成 UTF-8 字节数组再对字节数组加密。这样每个字节都在 0-255 范围内不会溢出。function utf8ToBytes(str) { const encoder new TextEncoder(); return Array.from(encoder.encode(str)); } function bytesToUtf8(bytes) { const decoder new TextDecoder(utf-8); return decoder.decode(new Uint8Array(bytes)); }但 jsencrypt 的encrypt方法接收的是字符串不是字节数组。所以需要把字节数组再转成一个“每个字符都在 0-255 范围内”的字符串也就是 Latin-1 编码的字符串function bytesToLatin1String(bytes) { return bytes.map(b String.fromCharCode(b)).join(); }这样传给encrypt的字符串每个字符都小于 256库内部处理时不会截断。后端解密后得到的是同样的 Latin-1 字符串再按 UTF-8 解码就能还原中文。4.3 解决方案二用 encodeURIComponent 中转还有一个更取巧的办法先用encodeURIComponent把中文转成百分号编码这样字符串里全是 ASCII 字符加密不会有任何问题。后端解密后得到%E4%B8%AD这样的串再decodeURIComponent还原。function encryptChineseSafe(text, encryptor) { const encoded encodeURIComponent(text); return encryptor.encrypt(encoded); } function decryptChineseSafe(cipher, decryptor) { const decoded decryptor.decrypt(cipher); return decodeURIComponent(decoded); }这个方案的好处是实现简单不用处理字节数组。缺点是密文体积会变大因为encodeURIComponent会把一个中文字符变成 9 个字符%E4%B8%AD。对于长文本这会显著增加加密块数。4.4 两种方案的对比与选择对比项UTF-8 字节方案encodeURIComponent 方案实现复杂度中等简单密文体积正常膨胀约 3 倍兼容性需要前后端约定编码前后端都用标准 API适用场景长文本、性能敏感短文本、快速接入我个人的选择是如果加密内容超过 100 字节用 UTF-8 字节方案如果只是加密一个密码或短 tokenencodeURIComponent更省事。但要注意encodeURIComponent不会编码A-Za-z0-9-_.!~*()这些字符如果原文包含这些解密后不需要额外处理因为decodeURIComponent能正确还原。踩过的坑有一次后端用 Java 解密Java 的URLDecoder.decode默认把解码成空格。如果原文里有encodeURIComponent会把它编码成%2B这没问题。但如果后端手动拼接了 URL 参数可能被提前处理掉。所以用这个方案时前后端要确认好解码方式。4.5 分段加密与中文处理的结合把分段和中文处理结合起来完整的加密函数是这样的function rsaEncryptLongSafe(plainText, publicKey) { const encryptor new JSEncrypt(); encryptor.setPublicKey(publicKey); const MAX_CHUNK_BYTES 245; const chunks splitByCharBoundary(plainText, MAX_CHUNK_BYTES); const encryptedChunks chunks.map(chunk { // 先转 UTF-8 字节再转 Latin-1 字符串 const bytes new TextEncoder().encode(chunk); const latin1Str Array.from(bytes) .map(b String.fromCharCode(b)) .join(); const result encryptor.encrypt(latin1Str); if (result false) { throw new Error(加密失败); } return result; }); return encryptedChunks.join(::); }对应的后端解密逻辑以 Java 为例伪代码String[] chunks cipher.split(::); StringBuilder sb new StringBuilder(); for (String chunk : chunks) { byte[] decrypted rsaDecrypt(Base64.decode(chunk), privateKey); // decrypted 是 UTF-8 字节直接转字符串 sb.append(new String(decrypted, StandardCharsets.UTF_8)); } return sb.toString();关键点在于前端把 UTF-8 字节转成 Latin-1 字符串加密后端解密后得到的是同样的字节序列直接用 UTF-8 解码即可。整个链路编码一致中文不会乱。5. 完整实操流程与关键环节5.1 环境准备与依赖安装前端项目用 npm 管理依赖的话安装 jsencryptnpm install jsencrypt如果项目不支持模块化可以直接引入 CDN 版本但要注意版本号不同版本的 API 可能有差异。我用的是 3.3.2这个版本对中文的处理相对稳定。后端需要对应的 RSA 解密能力。Java 用javax.crypto.CipherNode.js 用crypto.privateDecryptPython 用cryptography库。关键是填充模式要前后端一致PKCS#1 v1.5 对应 Java 的RSA/ECB/PKCS1Padding。5.2 密钥对的生成与格式转换密钥对可以用 openssl 生成# 生成 2048 位私钥 openssl genrsa -out private.pem 2048 # 从私钥提取公钥 openssl rsa -in private.pem -pubout -out public.pem生成的公钥是 PEM 格式前端直接用这个字符串就行。但要注意jsencrypt 要求公钥是-----BEGIN PUBLIC KEY-----开头的格式不是-----BEGIN RSA PUBLIC KEY-----。后者是 PKCS#1 格式需要转换openssl rsa -in private.pem -pubout -outform PEM -out public_pkcs8.pem注意私钥绝对不能出现在前端代码里。我见过有项目把私钥也打包进前端这等于没有加密。私钥只能存在后端前端只拿公钥。5.3 前端加密的完整代码实现把前面的片段整合成一个可直接用的模块import JSEncrypt from jsencrypt; const MAX_CHUNK_BYTES 245; const CHUNK_SEPARATOR ::; function splitByCharBoundary(str, maxBytes) { const encoder new TextEncoder(); const chunks []; let current ; let currentBytes 0; for (const char of str) { const charBytes encoder.encode(char).length; if (currentBytes charBytes maxBytes) { chunks.push(current); current char; currentBytes charBytes; } else { current char; currentBytes charBytes; } } if (current) chunks.push(current); return chunks; } function chunkToLatin1(chunk) { const bytes new TextEncoder().encode(chunk); return Array.from(bytes).map(b String.fromCharCode(b)).join(); } export function rsaEncrypt(plainText, publicKey) { const encryptor new JSEncrypt(); encryptor.setPublicKey(publicKey); const chunks splitByCharBoundary(plainText, MAX_CHUNK_BYTES); const encrypted chunks.map(chunk { const latin1 chunkToLatin1(chunk); const result encryptor.encrypt(latin1); if (result false) { throw new Error(RSA 加密失败请检查公钥格式和长度); } return result; }); return encrypted.join(CHUNK_SEPARATOR); }调用方式const publicKey -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY-----; const sensitiveData 这是一段需要加密的中文内容包含地址、备注等信息; const cipher rsaEncrypt(sensitiveData, publicKey); console.log(cipher);5.4 后端解密的对应实现以 Node.js 为例后端解密逻辑const crypto require(crypto); const fs require(fs); const privateKey fs.readFileSync(private.pem, utf8); function rsaDecryptLong(cipherText) { const chunks cipherText.split(::); const decryptedChunks chunks.map(chunk { const buffer Buffer.from(chunk, base64); const decrypted crypto.privateDecrypt( { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING, }, buffer ); return decrypted; }); return Buffer.concat(decryptedChunks).toString(utf8); }这里Buffer.concat把多个解密块拼成一个完整字节序列最后统一按 UTF-8 解码。这样即使一个中文字符被分到两个块里实际上不会因为前端按字符边界切分了也能正确还原。5.5 性能考量与优化建议RSA 加密是计算密集型操作2048 位密钥单次加密大约 1-2 毫秒。如果明文有 10KB按 245 字节分块需要约 42 次加密总耗时 50-80 毫秒。这个延迟在用户点击提交按钮时是可以接受的但如果放在输入框的input事件里实时加密就会明显卡顿。优化思路有几个只加密敏感字段不要整个表单加密。密码、身份证号、银行卡号这些才需要用户名、备注这些可以明文传输。防抖处理如果确实需要实时加密加 300 毫秒防抖。Web Worker把加密操作放到 Worker 线程避免阻塞主线程。但 jsencrypt 在 Worker 里需要额外处理因为 Worker 没有window对象。混合加密对于超长文本用 AES 加密内容再用 RSA 加密 AES 密钥。这是 HTTPS 本身的思路前端也可以借鉴。但实现复杂度高一般业务场景用分段 RSA 就够了。6. 常见问题与排查技巧实录6.1 加密返回 false 的几种原因encrypt()返回false是最常见的报错原因通常有这几类现象可能原因排查方法短文本也返回 false公钥格式错误检查是否包含头尾标记是否有多余空格中文返回 false字符编码溢出用 Latin-1 转换后再加密长文本返回 false超过单块限制实现分段加密偶尔返回 false公钥被换行截断确保公钥字符串完整公钥格式问题占了八成。jsencrypt 对公钥的格式要求很严格-----BEGIN PUBLIC KEY-----和-----END PUBLIC KEY-----必须各占一行中间的内容不能有换行。从后端接口拿公钥时如果返回的是 JSON换行符可能被转义成\n需要手动替换回来。6.2 解密后中文乱码的排查路径如果后端解密出来是乱码按这个顺序排查确认前端是否做了 UTF-8 转换。如果直接加密原始字符串中文必乱。确认后端解码方式。前端用 Latin-1 转换的后端解密后得到的是字节必须用 UTF-8 解码不能用 Latin-1。确认分段拼接顺序。如果密文块顺序错了解密出来的字节序列就是乱的。确认填充模式一致。前端 PKCS#1 v1.5后端也必须用 PKCS#1 v1.5用 OAEP 会直接解密失败。我遇到过一次诡异的情况前端加密正常后端解密也正常但中文就是乱。最后发现是后端在解密后做了一次new String(bytes)没有指定字符集用了系统默认的 GBK。改成new String(bytes, StandardCharsets.UTF_8)就好了。6.3 分段数量与密文体积的关系分段加密后密文体积会膨胀。每块明文最多 245 字节加密后变成 256 字节的密文再 Base64 编码变成约 344 字节。加上分隔符实际传输体积大约是明文的 1.5 倍。如果明文是 1KB密文大约 1.5KB。这个膨胀率在可接受范围内。但如果明文是 100KB密文就是 150KB请求体可能超过某些网关的限制。所以超长文本不建议用 RSA 直接加密应该用混合加密方案。6.4 常见问题速查表问题根因解决方案encrypt 返回 false公钥格式错误检查 PEM 格式去除多余空白中文解密乱码编码不一致前端 UTF-8 转 Latin-1后端 UTF-8 解码长文本加密失败超过单块限制按 245 字节分段加密解密后数据截断密文块丢失检查分隔符是否被 URL 编码性能卡顿同步加密阻塞主线程减少加密字段或放 Worker后端解密报错填充模式不匹配统一用 PKCS#1 v1.5密文包含特殊字符Base64 换行用replace(/\n/g, )清理独家避坑技巧在开发阶段让后端提供一个解密接口前端加密后直接调这个接口验证。这样能在联调前就发现编码和分段问题比等到正式接口报错再排查效率高得多。我习惯在项目里加一个debugDecrypt方法只在开发环境启用。7. 一些实际项目中的经验补充7.1 公钥动态获取的处理实际项目中公钥通常不是硬编码在前端的而是通过接口动态获取。这就带来一个问题公钥获取是异步的而加密操作可能在公钥还没回来时就触发了。我的处理方式是在应用初始化时预加载公钥存到内存或 sessionStorage。如果加密时公钥还没准备好就抛出一个明确的错误而不是静默失败。这样调用方能感知到问题做重试或提示。另外公钥接口本身也可能被篡改。如果攻击者替换了公钥前端加密的数据就能被攻击者用自己的私钥解开。所以公钥接口必须走 HTTPS且最好有签名校验。当然这是更高层面的安全设计一般业务场景做到 HTTPS 就够了。7.2 与 HTTPS 的关系有人会问既然已经用了 HTTPS前端还需要 RSA 加密吗这个问题没有标准答案取决于安全等级要求。HTTPS 保护的是传输层数据在客户端和服务端之间是加密的。但在服务端内部数据可能是明文的日志里可能记录了明文密码数据库里可能存了明文敏感信息。前端 RSA 加密相当于多了一层保护让敏感数据在到达业务逻辑之前就是密文。金融、医疗这类强监管行业通常要求前端加密。普通业务场景HTTPS 加后端哈希存储基本够用。我的建议是密码字段做前端 RSA 加密其他敏感字段根据合规要求决定。7.3 密钥轮换的考虑RSA 密钥对不是生成一次就永远不换的。定期轮换密钥是安全最佳实践。但前端加密场景下密钥轮换会带来兼容问题旧密文用旧私钥解密新密文用新私钥解密。处理方式是在密文里带上密钥版本号后端根据版本号选择对应的私钥。前端从接口获取公钥时同时拿到版本号加密时把版本号拼在密文前面。这样后端就能正确路由。这个机制在项目初期就要设计好后期再加会很麻烦。我见过一个项目上线半年后才想起密钥轮换结果发现所有历史密文都无法解密只能强制用户重新提交。7.4 测试用例的设计加密功能的测试不能只测“能加密能解密”还要覆盖边界情况空字符串加密单字符加密刚好 245 字节的明文246 字节的明文触发分段纯中文、纯英文、中英混合包含 emoji 的文本包含特殊字符、/、的文本这些用例能覆盖 95% 的线上问题。特别是 emoji它的 UTF-8 编码是 4 个字节for...of遍历时按码点处理但charCodeAt会返回代理对处理不当就会乱码。用TextEncoder就没这个问题因为它直接处理码点。7.5 最后分享一个小技巧如果你在排查加密问题时不确定是前端加密错了还是后端解密错了可以做一个中间验证前端加密后把密文复制到后端的一个测试接口用私钥解密打印出字节数组的十六进制。然后手动对比原文的 UTF-8 字节。如果字节一致说明加密没问题问题在解密后的解码环节如果字节不一致说明加密环节有丢失。这个方法我用了很多次能快速定位问题在哪一端。比反复改代码试错高效得多。另外jsencrypt 的decrypt方法在前端也可以用来验证用私钥解密自己加密的密文看能否还原。虽然前端不应该有私钥但在开发阶段临时验证一下是没问题的上线前记得删掉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑