戰(zhàn):jsencrypt避坑與兼容指南)
簡(jiǎn)介這份資源面向需要在Web前端或uni-app項(xiàng)目中實(shí)現(xiàn)RSA加密解密的開發(fā)者重點(diǎn)解決普通jsencrypt.js在uni-app環(huán)境下引入報(bào)錯(cuò)的問題。包內(nèi)提供經(jīng)過修改、可在uni-app中正常運(yùn)行的jsencrypt.js以及封裝好的rsa.js工具文件配合說明文檔幫助讀者快速完成公鑰加密、私鑰解密的接入流程適用于登錄密碼、敏感參數(shù)等需要加密傳輸?shù)膱?chǎng)景。資源共3個(gè)文件以2個(gè)js腳本和1個(gè)txt說明為主壓縮包約37KB體積輕量、便于直接放入項(xiàng)目utils目錄使用。目前已有2805人學(xué)習(xí)下載說明該方案在實(shí)際項(xiàng)目中具備一定參考價(jià)值。讀者可據(jù)此省去反復(fù)查找兼容方案的時(shí)間直接獲得可運(yùn)行的加密解密方法與排錯(cuò)思路降低前端RSA接入門檻。1. 前端 RSA 加密到底該不該做一次登錄接口改造的翻車復(fù)盤去年接手一個(gè)跨平臺(tái)項(xiàng)目登錄接口明文傳密碼被安全掃描揪出來(lái)要求前端必須做 RSA 加密。當(dāng)時(shí)第一反應(yīng)是上 jsencrypt畢竟關(guān)鍵詞一搜 rsa jsencrypt uniapp 全是它。結(jié)果第一版上線當(dāng)天就翻車安卓真機(jī)正常iOS 上解密出來(lái)是亂碼后端一臉懵說你們前端傳的什么鬼。排查到凌晨才發(fā)現(xiàn)是 uniapp 某些環(huán)境下 jsencrypt 的隨機(jī)數(shù)填充依賴window.crypto而小程序和 App 端根本沒有這個(gè)對(duì)象。這件事讓我意識(shí)到j(luò)sencrypt 在前端做 RSA 加密不是「引入一個(gè)庫(kù)調(diào)兩個(gè)方法」那么簡(jiǎn)單。它涉及密鑰格式、填充模式、跨端兼容、密文編碼四個(gè)層面的坑。這份資源就是圍繞 jsencrypt 在 uniapp 多端環(huán)境下的完整落地從密鑰生成、公鑰加密、私鑰解密到跨端適配把每個(gè)環(huán)節(jié)的參數(shù)和邊界講清楚。適合正在做登錄加密、接口簽名、敏感字段傳輸?shù)那岸撕腿珬S绕涫怯?uniapp 一套代碼跑 H5、小程序、App 的團(tuán)隊(duì)。如果你只是 H5 單端看完能省掉至少兩次返工如果你是多端這篇能幫你避開我踩過的所有雷。2. jsencrypt 的加密原理與密鑰格式為什么你的公鑰后端解不開2.1 RSA 填充模式與 jsencrypt 的默認(rèn)行為RSA 加密本身只做數(shù)學(xué)運(yùn)算真正決定密文能不能被后端解開的是填充模式。jsencrypt 默認(rèn)使用 PKCS#1 v1.5 填充這也是 Java 的Cipher.getInstance(RSA)默認(rèn)模式。但問題在于很多后端用 Hutool 或 BouncyCastle 時(shí)默認(rèn)走的是RSA/ECB/PKCS1Padding看起來(lái)一樣實(shí)際對(duì)密鑰格式的要求不同。jsencrypt 接受的公鑰必須是 PEM 格式的 SubjectPublicKeyInfo 結(jié)構(gòu)也就是-----BEGIN PUBLIC KEY-----開頭的那種。如果你從后端拿到的是-----BEGIN RSA PUBLIC KEY-----那是 PKCS#1 格式j(luò)sencrypt 直接報(bào)Invalid public key。這個(gè)區(qū)別在 Java 后端用KeyPairGenerator生成時(shí)特別容易混淆因?yàn)?Java 默認(rèn)輸出的是 PKCS#8 私鑰和 X.509 公鑰但有些老代碼會(huì)手動(dòng)轉(zhuǎn)成 PKCS#1。常見做法是讓后端統(tǒng)一輸出 PKCS#8 格式前端拿到后去掉頭尾和換行再傳給 jsencrypt。如果你拿到的是 PKCS#1可以用 openssl 轉(zhuǎn)# 將 PKCS#1 公鑰轉(zhuǎn)為 PKCS#8 openssl rsa -RSAPublicKey_in -in pkcs1_public.pem -pubout -out pkcs8_public.pem # 將 PKCS#1 私鑰轉(zhuǎn)為 PKCS#8 openssl pkcs8 -topk8 -inform PEM -in pkcs1_private.pem -outform PEM -nocrypt -out pkcs8_private.pem轉(zhuǎn)換后前端才能正常setPublicKey。這一步不做后面所有調(diào)試都是白費(fèi)。2.2 密鑰長(zhǎng)度與分段加密的邊界jsencrypt 支持 512 到 4096 位密鑰但實(shí)際項(xiàng)目里 1024 位已經(jīng)不安全2048 位是底線。密鑰長(zhǎng)度直接決定單次能加密的明文長(zhǎng)度2048 位密鑰最多加密 245 字節(jié)PKCS#1 v1.5 填充下超過這個(gè)長(zhǎng)度encrypt返回 false而且不報(bào)錯(cuò)只返回 false。這是最坑的地方很多人拿到 false 以為加密成功結(jié)果傳了個(gè)空字符串給后端。如果你要加密的字段超過 245 字節(jié)比如整個(gè)用戶信息 JSON就必須分段加密。jsencrypt 本身不提供分段方法需要自己按 245 字節(jié)切分每段單獨(dú)加密最后拼接。但更推薦的做法是RSA 只用來(lái)加密一個(gè)隨機(jī)生成的 AES 密鑰真正的大數(shù)據(jù)用 AES 加密。這就是混合加密也是 HTTPS 的實(shí)際做法。import JSEncrypt from jsencrypt const encryptor new JSEncrypt() encryptor.setPublicKey(publicKey) // 單段加密明文不超過 245 字節(jié) const ciphertext encryptor.encrypt(plaintext) if (ciphertext false) { // 明文超長(zhǎng)或公鑰格式錯(cuò)誤 console.error(加密失敗檢查公鑰格式或明文長(zhǎng)度) } // 分段加密示例 function encryptLong(text, maxChunk 245) { const chunks [] for (let i 0; i text.length; i maxChunk) { chunks.push(text.slice(i, i maxChunk)) } return chunks.map(chunk encryptor.encrypt(chunk)).join() }參數(shù)說明maxChunk在 2048 位密鑰下是 2451024 位是 1174096 位是 501。分段后拼接的密文需要后端按同樣規(guī)則切分再逐段解密否則解出來(lái)是亂碼。這個(gè)方案只適合短文本長(zhǎng)文本務(wù)必走混合加密。3. uniapp 多端集成 jsencrypt從 H5 到小程序的兼容改造3.1 安裝與條件編譯的寫法uniapp 項(xiàng)目里裝 jsencrypt 直接用 npm 就行但要注意版本。jsencrypt 3.x 和 2.x 的 API 有差異3.x 默認(rèn)導(dǎo)出變了而且對(duì)window對(duì)象的依賴更重。小程序和 App 端沒有window所以必須做條件編譯。npm install jsencrypt3.3.2 --save為什么鎖 3.3.2因?yàn)?3.3.2 之后的一些版本在小程序端會(huì)報(bào)ReferenceError: window is not defined而 3.3.2 是最后一個(gè)在小程序端相對(duì)穩(wěn)定的版本。如果你用 uniapp 的 vue3 版本還需要在vite.config.js里把 jsencrypt 加入optimizeDeps.include否則 H5 端會(huì)報(bào)模塊找不到。// utils/rsa.js let JSEncrypt null // #ifdef H5 import JSEncryptH5 from jsencrypt JSEncrypt JSEncryptH5 // #endif // #ifdef MP-WEIXIN || APP-PLUS // 小程序和 App 端使用改造版去掉 window 依賴 import JSEncryptMP from jsencrypt/bin/jsencrypt.min JSEncrypt JSEncryptMP // #endif export function createEncryptor(publicKey) { const encryptor new JSEncrypt() encryptor.setPublicKey(publicKey) return encryptor }條件編譯的關(guān)鍵是#ifdef指令uniapp 在編譯時(shí)會(huì)根據(jù)平臺(tái)剔除不滿足條件的代碼。H5 端直接用完整版小程序和 App 端用bin/jsencrypt.min這個(gè)版本去掉了部分瀏覽器 API 依賴但仍有navigator的引用需要在main.js里做全局兜底。3.2 小程序端的 window 與 navigator 兜底小程序端沒有window和navigatorjsencrypt 內(nèi)部會(huì)調(diào)用window.crypto.getRandomValues來(lái)生成隨機(jī)數(shù)。如果不處理加密會(huì)直接拋異常。常見做法是在main.js最頂部注入一個(gè)假的全局對(duì)象// main.js // #ifdef MP-WEIXIN if (typeof window undefined) { global.window { crypto: { getRandomValues: (array) { // 小程序端用 wx.getRandomValues 替代 const randomValues new Uint8Array(array.length) wx.getRandomValues(randomValues) array.set(randomValues) return array } }, navigator: { appName: Netscape, userAgent: uniapp } } } // #endif這段代碼必須在importjsencrypt 之前執(zhí)行否則 jsencrypt 初始化時(shí)就會(huì)報(bào)錯(cuò)。wx.getRandomValues是小程序提供的加密安全隨機(jī)數(shù)接口返回的隨機(jī)值質(zhì)量足夠 RSA 使用。App 端可以用plus.crypto或者直接復(fù)用 H5 的window.crypto因?yàn)?App 的 webview 環(huán)境其實(shí)有完整的 window 對(duì)象但 uniapp 的 vue 頁(yè)面里訪問不到所以還是走兜底更穩(wěn)。提示這段兜底代碼不要寫在utils/rsa.js里因?yàn)槟K導(dǎo)入順序不可控寫在main.js頂部才能保證在 jsencrypt 加載前執(zhí)行。3.3 密文編碼與后端解密的對(duì)接jsencrypt 的encrypt方法默認(rèn)返回 Base64 編碼的密文但有些后端期望 Hex 編碼。這個(gè)差異會(huì)導(dǎo)致后端解密報(bào)BadPaddingException或直接解出亂碼。對(duì)接前必須和后端確認(rèn)三件事密文編碼是 Base64 還是 Hex、填充模式是 PKCS#1 還是 OAEP、字符集是 UTF-8 還是 GBK。// Base64 轉(zhuǎn) Hex 的工具函數(shù) function base64ToHex(base64) { const raw atob(base64) let hex for (let i 0; i raw.length; i) { const charCode raw.charCodeAt(i) hex charCode.toString(16).padStart(2, 0) } return hex } // 如果后端要 Hex加密后轉(zhuǎn)換 const ciphertext encryptor.encrypt(plaintext) const hexCiphertext base64ToHex(ciphertext)參數(shù)說明atob在小程序端不存在需要用wx.base64ToArrayBuffer替代。所以這段轉(zhuǎn)換也要做條件編譯。最省事的方案是讓后端統(tǒng)一接受 Base64前端不做任何轉(zhuǎn)換減少一層出錯(cuò)概率。如果后端堅(jiān)持要 Hex就在 H5 端用atob小程序端用wx.base64ToArrayBuffer加手動(dòng)轉(zhuǎn) Hex。4. 避坑與排查五個(gè)讓加密結(jié)果對(duì)不上的真實(shí)原因4.1 現(xiàn)象加密返回 false但控制臺(tái)無(wú)報(bào)錯(cuò)原因明文超過密鑰長(zhǎng)度上限或者公鑰格式不是 PKCS#8。jsencrypt 在遇到這兩種情況時(shí)都返回 false不拋異常。解決先檢查公鑰是否以-----BEGIN PUBLIC KEY-----開頭再檢查明文長(zhǎng)度是否超過 245 字節(jié)2048 位密鑰。如果都正常打印encryptor.getKey()看公鑰是否被正確解析。4.2 現(xiàn)象H5 正常小程序解密后是亂碼原因小程序端window.crypto.getRandomValues兜底沒生效jsencrypt 用了不安全的隨機(jī)數(shù)導(dǎo)致填充數(shù)據(jù)不符合 PKCS#1 規(guī)范。解決確認(rèn)main.js頂部的兜底代碼在 jsencrypt 導(dǎo)入前執(zhí)行并且wx.getRandomValues返回的是Uint8Array而不是普通數(shù)組。4.3 現(xiàn)象后端報(bào)javax.crypto.BadPaddingException: Decryption error原因填充模式不匹配。jsencrypt 默認(rèn) PKCS#1 v1.5如果后端用了 OAEP 填充必然報(bào)這個(gè)錯(cuò)。解決讓后端確認(rèn)Cipher.getInstance的參數(shù)如果是RSA/ECB/OAEPWithSHA-256AndMGF1Padding前端需要換用支持 OAEP 的庫(kù)jsencrypt 不支持 OAEP只能換node-forge或jsrsasign。4.4 現(xiàn)象iOS App 端加密后后端解出空字符串原因iOS 的 JavaScriptCore 對(duì)atob和btoa的實(shí)現(xiàn)有差異jsencrypt 內(nèi)部用btoa做 Base64 編碼時(shí)可能返回空。解決在 App 端用plus.android或plus.ios的原生 Base64 方法替代或者直接引入base-64這個(gè) npm 包做 polyfill。4.5 現(xiàn)象每次加密結(jié)果都不一樣后端說無(wú)法比對(duì)原因RSA 加密本身是隨機(jī)填充同樣的明文每次加密結(jié)果都不同這是正常現(xiàn)象。如果后端需要比對(duì)密文應(yīng)該比對(duì)解密后的明文而不是密文。解決跟后端說清楚 RSA 的隨機(jī)性或者改用簽名方案私鑰簽名、公鑰驗(yàn)簽來(lái)做一致性校驗(yàn)。5. 進(jìn)階密鑰輪換與前端緩存的取舍密鑰不能寫死在代碼里這是基本安全常識(shí)。但前端每次啟動(dòng)都去后端拉公鑰會(huì)多一次網(wǎng)絡(luò)請(qǐng)求影響登錄速度。我一般用「本地緩存 版本號(hào)校驗(yàn)」的方案公鑰帶一個(gè)version字段前端緩存到uni.getStorageSync每次啟動(dòng)先讀緩存同時(shí)異步請(qǐng)求最新版本號(hào)如果版本變了再更新公鑰。const PUBLIC_KEY_CACHE rsa_public_key const PUBLIC_KEY_VERSION rsa_public_key_version export async function getPublicKey() { const cachedKey uni.getStorageSync(PUBLIC_KEY_CACHE) const cachedVersion uni.getStorageSync(PUBLIC_KEY_VERSION) // 先返回緩存保證登錄不阻塞 if (cachedKey) { // 異步檢查版本 checkKeyVersion(cachedVersion) return cachedKey } // 無(wú)緩存則同步請(qǐng)求 const { key, version } await fetchPublicKey() uni.setStorageSync(PUBLIC_KEY_CACHE, key) uni.setStorageSync(PUBLIC_KEY_VERSION, version) return key } async function checkKeyVersion(localVersion) { try { const { version } await fetchPublicKeyVersion() if (version ! localVersion) { const { key } await fetchPublicKey() uni.setStorageSync(PUBLIC_KEY_CACHE, key) uni.setStorageSync(PUBLIC_KEY_VERSION, version) } } catch (e) { // 版本檢查失敗不影響本次登錄 } }參數(shù)說明fetchPublicKey和fetchPublicKeyVersion是后端接口返回格式建議統(tǒng)一為{ key: -----BEGIN PUBLIC KEY-----..., version: v2 }。緩存策略上公鑰可以長(zhǎng)期緩存但版本號(hào)每次啟動(dòng)都要查因?yàn)槊荑€輪換通常發(fā)生在后端前端需要及時(shí)感知。驗(yàn)證加密是否生效最直接的方法是在后端解密后打印明文和前端傳入的原始字符串做比對(duì)。如果一致說明整條鏈路通了。我習(xí)慣在測(cè)試環(huán)境加一個(gè)/api/debug/decrypt接口專門用來(lái)驗(yàn)證前端密文上線前刪掉。從那以后我每次接入新的加密方案都強(qiáng)制走一遍「H5 加密 → 小程序加密 → App 加密 → 后端解密比對(duì)」的完整流程少一步都可能在上線后翻車。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取