:crypto-js實現(xiàn)敏感數(shù)據(jù)安全傳輸)
前端和后端之間傳敏感數(shù)據(jù)很多團(tuán)隊第一反應(yīng)是“我上了HTTPS就行了”但實際做過安全評審的都知道HTTPS只保護(hù)鏈路不保護(hù)業(yè)務(wù)數(shù)據(jù)本身。一旦日志被撈走、反向代理被碰、或者上游服務(wù)明文落庫數(shù)據(jù)照樣裸奔。我之前的項目里正好碰到類似的硬需求Vue前端要把用戶敏感信息傳到某后端服務(wù)鏈路要求不能出現(xiàn)明文于是就把AES加密這套方案完整落地了。這篇文章會把前端Vue用crypto-js做AES加密、后端做解密的完整過程講清楚包括參數(shù)選擇、代碼封裝、踩過的坑以及一些常規(guī)文檔里不會寫的東西。如果你正打算做前后端數(shù)據(jù)加密或者只是好奇AES在真實項目里怎么用這篇文章應(yīng)該是能直接拿去參考的。1. 為什么要在前后端之間做AES加密1.1 這個方案解決的真實問題先說結(jié)論AES加密不是用來替代HTTPS的它解決的是HTTPS兜不住的那一層。幾個實際場景你自己感受一下。第一前端頁面在瀏覽器里跑用戶輸入的身份證號、手機(jī)號、銀行卡信息如果直接用明文拼進(jìn)請求體哪怕有HTTPS只要任何一層日志記錄了請求體敏感字段就漏了。第二很多系統(tǒng)會加網(wǎng)關(guān)或者日志采集組件請求體經(jīng)常會被整體打印這就是明文大范圍暴露的高發(fā)區(qū)域。第三后端的數(shù)據(jù)庫表如果也按明文存儲一旦備份文件泄露那更是一鍋端。當(dāng)初項目組的做法是把“傳輸加密”和“存儲加密”分開設(shè)計存儲部分單獨走庫表加密傳輸部分就是用AES做業(yè)務(wù)字段加密。需求很明確前端在發(fā)送前把明文變成密文后端收到以后解密中間任何環(huán)節(jié)看到的都是不可讀的密文串。1.2 對稱加密為什么適合這個場景對稱加密和非對稱加密選型的時候確實糾結(jié)過一輪。RSA是非對稱公鑰加密、私鑰解密好處是公鑰可以隨便丟私鑰留后端前端不需要知道私鑰。聽起來很完美但問題是RSA加密的數(shù)據(jù)有長度限制2048位密鑰最多只能加密約117字節(jié)的明文你要傳一個幾百字的表單得分片加密繁瑣不說性能還慢。AES是對稱加密加解密用的都是同一把密鑰速度快適合批量數(shù)據(jù)單次能處理的數(shù)據(jù)量遠(yuǎn)大于RSA。密鑰交給前后端各持一份前端加密后端解密。它的弱點也很直觀密鑰不能泄露一旦密鑰出了前端代碼倉庫或者后端配置中心整個體系就廢了。這個項目選AES還有一個原因前后端都有成熟的實現(xiàn)庫前端是crypto-js后端無論是Node.js還是Java標(biāo)準(zhǔn)庫直接支持不需要額外引入重量級依賴。整個鏈路做起來非常輕半天就能把代碼跑通。1.3 方案落地后的整體架構(gòu)整個加密鏈路按三個階段劃分。第一階段前端在JS層調(diào)用封裝好的AES工具函數(shù)把原始請求體里的敏感字段做加密生成的是Base64編碼的密文。第二階段走正常的HTTP請求請求體里攜帶的是密文字符串。第三階段后端接口收到密文調(diào)用AES解密工具類還原出明文再做業(yè)務(wù)處理。從調(diào)用方視角看只是傳了一個“看起來像亂碼”的字符串加密細(xì)節(jié)全被封裝在工具類里。從后端視角看解密邏輯也封裝在服務(wù)端工具類里Controller層基本上無感。這套架構(gòu)好處在于加解密邏輯都收斂在同一個模塊后續(xù)要換密鑰或者升級算法不用全工程到處改。2. AES加密的核心概念2.1 密鑰、IV、分組模式AES本身是分組加密算法明文會被切成固定長度的塊AES-128按16字節(jié)分塊AES-192和AES-256也按16字節(jié)分塊只是密鑰長度不同。每個明文塊獨立加密但如果你直接每個塊獨立加密相同明文塊會產(chǎn)生相同密文塊這就有嚴(yán)重的模式泄露問題。所以實際使用時必須配合分組模式讓每個密文塊依賴于前一個塊把明文規(guī)律打散。這里不得不提兩個參數(shù)密鑰和IV。密鑰Key是真正的加密鑰匙AES-128就是16字節(jié)AES-256就是32字節(jié)。IV初始化向量是給第一塊明文做“隨機(jī)擾動”的相當(dāng)于給加密過程加一個初始偏移量每個分組模式對IV的處理不一樣但I(xiàn)V的常見長度也是16字節(jié)。項目里選擇的是AES-256-CBC組合32字節(jié)密鑰16字節(jié)IVCBC分組模式。為什么是256而不是128純屬安全冗余考量既然前后端跑AES在性能上差異不大能上256就不留128的短板。2.2 ECB和CBC怎么選ECB是最簡單的分組模式每個明文塊獨立加密不依賴其他塊。它的致命傷在于如果明文里有兩塊內(nèi)容相同密文里也會出現(xiàn)相同的塊這樣明文的排列規(guī)律就暴露了。最典型的例子就是加密一張圖片用ECB模式加密之后圖片輪廓還能隱約看出來。這種模式在真實項目里基本被廢了。CBC模式是每個明文塊先與前一個密文塊做異或然后再加密。第一個明文塊沒有前一個密文塊就用IV來異或。這樣一來相同明文在不同位置、不同IV下加密出來的結(jié)果完全不一樣明文規(guī)律被打亂了。代價是不能并行加密而且前一個塊出錯會影響下一個塊但對前后端小數(shù)據(jù)量加密來說這個代價可以忽略。項目里用CBC而不是更先進(jìn)的GCM主要考慮還是兼容性和實現(xiàn)成熟度。CBC是目前各種語言之間最容易對齊的AES模式加上PKCS7填充前后端基本不會出現(xiàn)“加密能過、解密不過”的問題。GCM雖然帶認(rèn)證能防篡改但跨語言實現(xiàn)時需要注意的細(xì)節(jié)更多第一次搞加解密不建議直接上。2.3 填充、編碼、Base64AES要求明文長度必須是16字節(jié)的整數(shù)倍實際業(yè)務(wù)數(shù)據(jù)很難恰好滿足比如加密一個“123456”只有6個字節(jié)所以必須填充。最常見的填充模式是PKCS7缺幾個字節(jié)就補幾個值為“缺少數(shù)”的字節(jié)。缺5個字節(jié)就補5個0x05如果明文剛好是16的倍數(shù)還要額外加一整塊0x10否則解密端沒辦法判斷末尾是不是填充數(shù)據(jù)。Java里寫的PKCS5Padding和前端crypto-js里的Pkcs7實際它們在這個場景下是同一回事因為Java的PKCS5Padding在AES分組大小16字節(jié)的語境下底層實現(xiàn)跟PKCS7一致。這個知識點特別容易讓第一次做AES的人迷茫我第一次對接時也繞了一下。編碼環(huán)節(jié)加密結(jié)果是二進(jìn)制字節(jié)流直接放進(jìn)JSON會亂碼所以要轉(zhuǎn)成可打印字符串。最通用的方式是Base64前端加密后用toString()直接得到Base64串后端用Base64解碼器還原字節(jié)數(shù)組再解密。整個鏈路統(tǒng)一走Base64基本不用考慮其他編碼方案。3. 前端Vue工程實現(xiàn)AES加密3.1 引入crypto-js依賴前端庫選了crypto-js它是一個非常成熟的純JS加密庫支持AES、DES、SHA系列等各種算法體積不算小但項目里只用到AES按需引入模塊就好。安裝命令很簡單npm或者yarn都行npm install crypto-js這里有個坑crypto-js的版本差異會導(dǎo)致模塊導(dǎo)入方式不一樣。如果你用的Vue2配合較老的項目可能直接import CryptoJS from crypto-js而新版項目里這么做也沒問題因為它默認(rèn)導(dǎo)出了整個庫。如果你只想引入AES模塊可以這么做import CryptoJS from crypto-js;用整個庫的導(dǎo)入方式在構(gòu)建時體積會大一點但如果項目里已經(jīng)有crypto-js其他算法在跑那就直接復(fù)用全局導(dǎo)入。我的項目里是統(tǒng)一用全局導(dǎo)入省得維護(hù)多個子模塊路徑。3.2 封裝加密工具類前端不能把加密邏輯散落在各個頁面組件里所有加解密必須收斂到一個工具文件統(tǒng)一出入口。我習(xí)慣建一個src/utils/crypto.js專門放AES加密相關(guān)邏輯后續(xù)要改密鑰或者調(diào)整算法只動這個文件就夠了。核心代碼看起來是這樣import CryptoJS from crypto-js; const AES_KEY your-32-byte-secret-key-0123456789abcdef; const AES_IV your-16-byte-iv-string; export function encryptByAES(plainText) { const key CryptoJS.enc.Utf8.parse(AES_KEY); const iv CryptoJS.enc.Utf8.parse(AES_IV); const encrypted CryptoJS.AES.encrypt(plainText, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.toString(); }幾個細(xì)節(jié)說明一下。密鑰長度必須嚴(yán)格匹配算法AES-256要求密鑰是32字節(jié)如果字符串長度不夠或者超過crypto-js不會直接報錯但后端解密時會得到一串亂碼或者直接異常這一點特別坑。IV固定是16字節(jié)不能隨便寫個8位字符串湊數(shù)。encrypted.toString()默認(rèn)輸出Base64格式后端拿到以后要按Base64解不要再去額外做URL編碼除非你想把密文直接塞到URL的query參數(shù)里那又是另一種處理方式。3.3 請求攔截器集成工具類封裝好以后怎么接入業(yè)務(wù)代碼是個選擇。最省事的做法是在axios請求攔截器里統(tǒng)一處理但有個前提只對指定的敏感字段做加密而不是對整個請求體加密。如果對整個請求體加密后端所有接口的入?yún)⒛P投家某山邮彰芪母膭用嫣?。我的做法是請求攔截器里遍歷一個配置好的敏感字段列表只要請求參數(shù)里出現(xiàn)這些字段名就原地替換成加密后的密文。字段列表比如[phone, idCard, account]每個字段的值在發(fā)送前都會被加密。這一段代碼大致長這樣import { encryptByAES } from /utils/crypto; const sensitiveFields [phone, idCard, account]; service.interceptors.request.use((config) { if (config.data typeof config.data object) { const data { ...config.data }; Object.keys(data).forEach((key) { if (sensitiveFields.includes(key) typeof data[key] string) { data[key] encryptByAES(data[key]); } }); config.data data; } return config; });這里有幾個坑需要特別提示。第一有些表單字段值是數(shù)字類型比如手機(jī)號存成了number你得先把數(shù)字轉(zhuǎn)成字符串再加密否則crypto-js會做隱式轉(zhuǎn)換加密出來的結(jié)果可能跟后端期望的不一致。第二GET請求的query參數(shù)也有敏感字段那就不能只在body里處理需要把query參數(shù)也走一層加密。第三如果字段值本身已經(jīng)是密文比如編輯頁面回顯時后端返回的就是密文前端就不要二次加密這個要做一次狀態(tài)判斷否則后端解密出來就是個雙重加密的亂碼。4. 后端服務(wù)的解密實現(xiàn)4.1 Node.js版解密服務(wù)后端如果用的是Node.js不需要裝第三方庫直接使用內(nèi)置的crypto模塊就能完成解密。Node.js的crypto模塊功能非常強(qiáng)唯一要注意的是API風(fēng)格新版本支持createDecipheriv別再用已經(jīng)廢棄的createDecipher。解密函數(shù)封裝如下const crypto require(crypto); const ALGORITHM aes-256-cbc; const AES_KEY your-32-byte-secret-key-0123456789abcdef; const AES_IV your-16-byte-iv-string; function decryptByAES(encryptedBase64) { const key Buffer.from(AES_KEY, utf8); const iv Buffer.from(AES_IV, utf8); const decipher crypto.createDecipheriv(ALGORITHM, key, iv); let decrypted decipher.update(encryptedBase64, base64, utf8); decrypted decipher.final(utf8); return decrypted; } module.exports { decryptByAES };這里必須要強(qiáng)調(diào)final()方法。很多新手寫到這里只調(diào)用了update()發(fā)現(xiàn)解出來還是不完整的字符串或者直接報錯原因就是final()才是把解密結(jié)果完整輸出的最后一步。update()方法在讀入密文數(shù)據(jù)時由于分組大小對齊的原因可能只是部分輸出最后一點數(shù)據(jù)必須通過final()沖刷出來。Buffer.from(AES_KEY, utf8)特別關(guān)鍵。有人習(xí)慣直接傳字符串給createDecipheriv比如crypto.createDecipheriv(ALGORITHM, AES_KEY, AES_IV)這種寫法在Node.js 12之前的版本還能正常工作新版本里密鑰和IV必須是Buffer類型否則會拋異常。為了統(tǒng)一我都是顯式轉(zhuǎn)Buffer。4.2 Java版解密實現(xiàn)后端的另一個常見技術(shù)棧就是Java。很多公司的服務(wù)端是Java Spring Boot跟Vue前端對接時解密邏輯用Java實現(xiàn)也很順手。Java標(biāo)準(zhǔn)庫自帶的javax.crypto完全夠用不需要引入額外的三方庫。核心代碼如下import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; public class AesUtil { private static final String ALGORITHM AES/CBC/PKCS5Padding; private static final String AES_KEY your-32-byte-secret-key-0123456789abcdef; private static final String AES_IV your-16-byte-iv-string; public static String decrypt(String encryptedBase64) throws Exception { byte[] encryptedBytes Base64.getDecoder().decode(encryptedBase64); SecretKeySpec keySpec new SecretKeySpec( AES_KEY.getBytes(StandardCharsets.UTF_8), AES); IvParameterSpec ivSpec new IvParameterSpec( AES_IV.getBytes(StandardCharsets.UTF_8)); Cipher cipher Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decryptedBytes cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } }這里有個關(guān)鍵點Java里寫的是PKCS5Padding前端crypto-js寫的是Pkcs7但它們在AES/CBC模式下是兼容的。原理我在前面提過Java的PKCS5Padding在AES的16字節(jié)分組下實際行為就是PKCS7。如果你在Java端寫成NoPadding那和前端就沒法對齊了解密一定要用PKCS5Padding。cipher.doFinal()在密鑰錯誤、密文被篡改、填充不合法等情況下都會拋異常異常類型是BadPaddingException或IllegalBlockSizeException。這里要提醒的是解密失敗時不要直接把異常堆棧拋給前端應(yīng)該統(tǒng)一轉(zhuǎn)成業(yè)務(wù)異常返回一個通用的“數(shù)據(jù)解析失敗”錯誤避免泄露內(nèi)部實現(xiàn)細(xì)節(jié)。4.3 返回數(shù)據(jù)同樣走加密鏈路一開始項目只做了前端請求加密、后端解密后來安全評審提了個問題接口響應(yīng)里的敏感數(shù)據(jù)也是明文。比如用戶詳情接口返回手機(jī)號、身份證號前端頁面直接渲染那中間鏈路照樣能看到。所以后來把響應(yīng)鏈路也加上了加密后端返回敏感字段前先加密前端拿到響應(yīng)后解密。這里就要反向再寫一個工具函數(shù)。前端解密時用同樣的密鑰和IV因為是對稱加密加解密同源。后端加密則復(fù)用同一個解密函數(shù)里的密鑰和IV只是把Cipher初始化模式改成ENCRYPT_MODE。Java后端響應(yīng)加密的輔助方法如下public static String encrypt(String plainText) throws Exception { byte[] plainBytes plainText.getBytes(StandardCharsets.UTF_8); SecretKeySpec keySpec new SecretKeySpec( AES_KEY.getBytes(StandardCharsets.UTF_8), AES); IvParameterSpec ivSpec new IvParameterSpec( AES_IV.getBytes(StandardCharsets.UTF_8)); Cipher cipher Cipher.getInstance(ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encryptedBytes cipher.doFinal(plainBytes); return Base64.getEncoder().encodeToString(encryptedBytes); }前端對應(yīng)的解密函數(shù)也必須補上export function decryptByAES(encryptedBase64) { const key CryptoJS.enc.Utf8.parse(AES_KEY); const iv CryptoJS.enc.Utf8.parse(AES_IV); const decrypted CryptoJS.AES.decrypt(encryptedBase64, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return decrypted.toString(CryptoJS.enc.Utf8); }注意decrypt方法返回的是WordArray對象要輸出字符串必須用toString(CryptoJS.enc.Utf8)很多人在這一步卡住解出來是亂碼就是因為沒指定編碼轉(zhuǎn)換。5. 踩坑記錄幾個“解密失敗”的真相5.1 密鑰和IV不一致導(dǎo)致的詭異結(jié)果前后端密鑰完全一致看起來是基本要求但實際項目里出問題最多的恰恰是這里。比如前端密鑰末尾多了個空格肉眼根本看不出來后端密鑰是標(biāo)準(zhǔn)32字節(jié)前端是33字節(jié)此時crypto-js還會用這個多一位的密鑰去加密但后端解密時會直接報密鑰長度錯誤。IV不一致就更隱蔽了。CBC模式第一個塊會跟IV做異或運算如果IV不一致解密出來的第一個塊必然是亂碼后面的塊可能正常也可能連帶報填充錯誤?,F(xiàn)實場景是前端把IV寫成了另一個項目的IV后端還是老項目的IV兩邊接口聯(lián)調(diào)時發(fā)現(xiàn)第一個字段解密出來是個空值或者一長串亂碼。排查這類問題的思路是先在本地做一次自測前端加密一段固定的字符串把密文打印出來后端用同一段密文解密如果本地都過不了就別浪費時間聯(lián)調(diào)了。5.2 Padding模式不匹配的問題前端Pkcs7和后端PKCS5Padding這對組合在實際工作中經(jīng)常會遇到反向問題有人隨意把后端改成NoPadding因為覺得反正數(shù)據(jù)不需要填充。這么一改解密出來的字符串末尾就會帶著一堆\u0000或者奇怪的二進(jìn)制填充符最典型的是字符串末尾多了幾個不可見字符。如果確實要用NoPadding那就要求明文長度本來就是16的倍數(shù)但業(yè)務(wù)上很難保障。所以最穩(wěn)妥的就是維持PKCS7/PKCS5Padding組合兩邊都不動這是最容易對齊的那條路。5.3 Base64傳輸中的加號問題前端生成的Base64字符串里可能帶、/、這些字符。如果在傳輸過程中密文被放在了URL的query參數(shù)里沒有做encodeURIComponent處理后端接收到的字符串里會被解析成空格/在部分網(wǎng)關(guān)里會被當(dāng)作路徑分隔符處理解密自然失敗。解決方案很簡單要么把密文放在請求體里而不是URL里要么拼接URL時統(tǒng)一做encodeURIComponent(encryptedStr)。后端取參時要記得URL解碼一次Java里就是URLDecoder.decode(param, UTF-8)Node.js就是decodeURIComponent(param)。我在項目里遇到過一次線上問題排查了半下午最后發(fā)現(xiàn)就是號變成了空格。前端打印密文也正常后端日志打出來也正常但就是莫名其妙多了一個空格檢查傳輸層編碼才發(fā)現(xiàn)問題。5.4 加密字段類型與空值處理敏感字段如果是null、空字符串、或者純數(shù)字處理邏輯一定要統(tǒng)一。前端加密空字符串時加密函數(shù)依然會輸出一串有效密文后端解密出來是空字符串這沒毛病。但如果你在前端先判斷非空才加密空值就傳了明文這就有邏輯漏洞。建議統(tǒng)一策略所有敏感字段不管是否為空都走同樣的加密函數(shù)讓全鏈路保持一致。數(shù)字類型也要注意。手機(jī)號如果前端存的是number加密函數(shù)會先轉(zhuǎn)成字符串再加密后端拿到解密后是字符串“13800138000”前后端類型對不上可能導(dǎo)致數(shù)據(jù)校驗出問題。建議前端在表單預(yù)處理階段就把這類值統(tǒng)一轉(zhuǎn)成字符串。6. 幾點安全建議和項目心得6.1 密鑰硬編碼是最大的短板整個AES方案的阿喀琉斯之踵就是密鑰管理。前端代碼跑在瀏覽器里任何代碼都可能被查看crypto-js加密邏輯會被直接看到密鑰也會暴露在被壓縮的JS文件中。所以必須清醒認(rèn)識到前端加密只是提高攻擊成本的“門鎖”不是防破解的“保險柜”。項目里做的折中方案是分層管理密鑰上線環(huán)境通過環(huán)境變量注入不寫進(jìn)前端源碼倉庫構(gòu)建時替換占位符。后端密鑰存儲在配置中心不落普通配置文件。這樣至少避免了“代碼倉庫泄露等于密鑰泄露”的最壞情況。6.2 性能和體量的權(quán)衡AES加密對CPU開銷不算大但要注意密文體積。Base64編碼會把數(shù)據(jù)增大四分之一到三分之一如果你對整個請求體加密請求體積會明顯變大。項目里只挑了敏感字段加密最多也就十幾個字段對整體性能影響很小。如果加密字段特別多建議把所有敏感字段統(tǒng)一拼成一個JSON字符串加密后放入一個單獨字段比如encryptedData傳給后端。后端解密后從JSON中取字段。這么做不僅減少加解密調(diào)用次數(shù)還讓網(wǎng)關(guān)注意到只有一個密文段日志被碰時暴露面更小。這個方案唯一要注意的是大字段拆包和超時配置如果密鑰和加密數(shù)據(jù)量大后端接口的響應(yīng)時間會有所增加。6.3 這套方案適合用在哪里回到標(biāo)題本身Vue前端加密、后端解密AES實現(xiàn)。這套方案真正適合的項目類型是HTTPS已經(jīng)上了但業(yè)務(wù)方有明確的數(shù)據(jù)安全審計要求敏感字段必須在傳輸鏈路中不可讀。它適合中小型系統(tǒng)、前后端分離的業(yè)務(wù)后臺、金融類Demo、需要快速演示加密效果的項目。如果項目要求防篡改、防重放建議后續(xù)再疊加消息認(rèn)證機(jī)制比如CBC模式下再對密文做HMAC或者直接升級到GCM模式。因為AES-CBC本身不提供完整性保護(hù)密文被篡改以后解密端只能得到亂碼卻無法識別這是攻擊行為還是傳輸損壞。我個人在實際落地中的體會是前后端加密最怕的不是算法選錯而是兩邊標(biāo)準(zhǔn)沒對齊。出問題的時候先確認(rèn)密鑰、IV、模式、填充、編碼五個點至少能把九成以上的解密失敗問題找到。先把工具類寫好、參數(shù)統(tǒng)一約定好再談安全增強(qiáng)和密鑰托管這條路走下來是最穩(wěn)的。