已經不是第一次寫這個主題了,最近有朋友拿 5 年前的《Web 應用中保證密碼傳輸安全》來問我:「爲何按你說的一步步作下來,後端解不出來呢?」加解密這種事情,差之毫釐謬以千里,我認爲多半就是什麼參數沒整對,仔細查查改對了就行。代碼拿來一看,傻眼了……沒毛病啊,爲啥解不出來呢?前端
時間久遠,原文附帶的源代碼已經下不下來了。翻閱各類參考連接的時候從 CodeProject 上找了個代碼,把各參數換過去一試,沒毛病呀!這可奇了怪了,因而去 RSA.js 的文檔(沒有專門的文檔,就是文檔註釋)中查,發現 RSA.js 在 2014 年 1 月加入了 Padding 參數,《Web 應用中保證密碼傳輸安全》雖然是 2014 年 2 月寫的,但可能陰差陽錯用到了老版本。java
不就是 Padding 嗎,文檔也懶得看了,先後端都指定 PKCS1Padding 試試。失敗!git
那暴力一點,全部 Padding 都試試!算法
前端使用 RSA.js 在 RSAAPP
中定義的 4 種 Padding,後端 C# 使用 RSAEncryptionPadding
中定義的 5 種 Padding,組合了 20 種狀況,逐一試驗……好吧,沒一個對的!數據庫
世界上這麼多樹,何須非要在這一棵上吊死,況且它尚未發佈到 npm …… 理由找夠了,咱就換!npm
網上搜了一圈以後,選擇了 JSEncrypt 這個庫。後端
在講 JSEncrypt 以前,我們回到「安全傳輸」這一主題。這一主題的關鍵技術在於加解密,提及加解密,那就是三大類算法:HASH(摘要)算法、對稱加密算法和非對稱加密算法。基本的安全傳輸過程能夠用一張圖來 展現:api
不過這只是最基本的安全傳輸理論,實際上,證書(公鑰)分發等方面仍然存在安全隱患,因此纔會有CA、纔會有受信根證書……不過這裏不做延展,只給個結論:在 Web 先後端傳輸這個問題上,HTTPS 就是最佳實踐,是首先 Web 傳輸解決方案,只有在不能使用 HTTPS 的狀況,才退而求其次,用本身的實現來提升一點安全門檻。緩存
JSEncrypt 一個月前剛有新版本,還算活躍。不過在使用方式上跟 RSA.js 不一樣,它不須要指定 RSA 的參數,而是直接導入一個 PEM 格式的密鑰(證書)。關於證書格式呢,就不在這裏科普了,總之 PEM 是一種文本格式,Base64 編碼。安全
既然 JSEnrypt 須要導入密鑰,這裏主要是須要導入公鑰。咱們來看看 C# 裏 RSACryptoServiceProvider
能導出些什麼,搜了一下 Export...
方法,導出公約相關的主要就這兩個:
由於原始需求是用 .NET,因此先研究 .NET 跟 JSEncrypt 的配合,後面再補充 NodeJS 和 Java 的。
ExportRSAPublicKey()
,以 PKCS#1 RSAPublicKey 格式導出當前密鑰的公鑰部分。ExportSubjectPublicKeyInfo()
,以 X.509 SubjectPublicKeyInfo 格式導出當前密鑰的公鑰部分。還有兩個 Try...
前綴的方法做用類似,能夠忽略。這兩個方法的區別就在於導出的格式不一樣,一個是 PKCS#1 (Public-Key Cryptography Standards),一個是 SPKI (Subject Public Key Info)。
JSEncrypt 能導入哪一種格式呢?文檔裏沒明確說明,不妨試試。
C# 中產生 RSA 密鑰對比較簡單,使用 RSACryptoServiceProvider
就行,好比產生一對 1024 位的 RSA 密鑰,並以 XML 格式導出:
// C# Code private RSACryptoServiceProvider GenerateRsaKeys(int keySize = 1024) { var rsa = new RSACryptoServiceProvider(keySize); var xmlPrivateKey = rsa.ToXmlString(true); // 若是須要單獨的公鑰部分,將傳入 `ToXmlString()` 改成 false 就好 // var xmlPublicKey = rsa.ToXmlString(false); File.WriteAllText("RSA_KEY", xmlPrivateKey); return rsa; }
爲了能在進程每次重啓都使用相同的密鑰,上面的示例將產生的 xmlPrivateKey
保存到文件中,重啓進程時能夠嘗試從文件加載導入。注意,因爲私鑰包含公鑰,因此只須要保存 xmlPrivateKey
就夠了。那麼加載的過程:
// C# Code private RSACryptoServiceProvider LoadRsaKeys() { if (!File.Exists("RSA_KEY")) { return null; } var xmlPrivateKey = File.ReadAllText("RSA_KEY"); var rsa = new RSACryptoServiceProvider(); rsa.FromXmlString(xmlPrivateKey); return rsa; }
先嚐試導入,不成再新生成的過程就一句話:
// C# Code var rsa = LoadRsaKeys() ?? GenerateRsaKeys();
導出 XML Key 是爲了持久化。JSEncrypt 須要的是 PEM 格式的證書,也就是 Base64 編碼的證書。ExportRSAPublicKey
和 ExportSubjectPublicKeyInfo
這兩個方法的返回類型都是 byte[]
,因此須要對它們進行 Base64 編碼。這裏使用 Viyi.Util 提供的 Base64Encode()
擴展方法來實現:
// C# Code var pkcs1 = rsa.ExportRSAPublicKey().Base64Encode(); var spki = rsa.ExportSubjectPublicKeyInfo().Base64Encode();
嚴格的說,PEM 格式還應該加上 -----BEGIN PUBLIC KEY-----
和 -----END PUBLIC KEY-----
這樣的標頭標尾,Base64 編碼也應該按每行 64 個字符進行折行處理。不過實測 JSEncrypt 導入時不會要求這麼嚴格,省了很多事。
剩下的就是將 pkcs1
和 spki
傳遞給前端了。Web 應用直接經過 API 返回一個 JSON,或者 TEXT 都行,根據接口規範來決定。固然也能夠經過拷貝/粘貼的方式來傳遞。這裏既然是在作實驗,那就用 Console.WriteLine
輸出到控制檯,經過剪貼板來傳遞好了。
我這裏 PKCS#1 導出的是長度爲 188 個字符的 Base64:
MIGJAoGB...tAgMBAAE=
SPKI 導出的是長度爲 216 個字符的 Base64:
MIGfMA0GC...QIDAQAB
JSEncrypt 提供了 setPublicKey()
和 setPrivateKey()
來導入密鑰。不過文檔中提到它們其實都是 setKey()
的別名,這點須要注意一下。爲了不語義不清,我建議直接使用 setKey()
。
You can use also setPrivateKey and setPublicKey, they are both alias to setKey
那麼導入公鑰並試驗加密的過程大概會是這樣:
// JavaScript Code const pkcs1 = "MIGJAoGB...tAgMBAAE="; // 注意,這裏的 KEY 值僅做示意,並不完整 const spki = "MIGfMA0GC...QIDAQAB"; // 注意,這裏的 KEY 值僅做示意,並不完整 [pkcs1, spki].forEach((pKey, i) => { const jse = new JSEncrypt(); jse.setKey(pKey); const eCodes = jse.encrypt("Hello World"); console.log(`[${i} Result]: ${eCodes}`); });
運行後獲得輸出(密文也是省略了中間很長一串的 ):
[0 Result]: false [1 Result]: ZkhFRnigoHt...wXQX4=
看這結果,沒啥懸念了,JSEncrypt 只認 SPKI 格式。
不過還得去 C# 中驗證這個密文是能夠解出來的。
上面生成的那一段 ZkhFRnigoHt...wXQX4=
拷貝到 C# 代碼中,用來驗證解密。C# 使用 RSACryptoServiceProvider.Decrypt()
實例方法來解密,這個方法的第 1 個參數是密文,類型 byte[]
,是以二進制數據的形式提供的。
第二個參數能夠是 boolean
類型,true
表示使用 OAEP
填充方式,false
表示使用 PKCS#1 v1.5
;這個參數也能夠是 RSAEncryptionPadding
對象,直接從預約義的幾個靜態對象中選擇一個就好。這些在文檔中都說得很清楚。由於通常都是使用的 PKCS 填充方式,因此此次賭一把,直接上:
// C# Code var eCodes = "ZkhFRnigoHt...wXQX4="; // 示例代碼這裏省略了中間大部份內容 var rsa = LoadRsaKeys(); // rsa 確定是使用以前生成的密鑰對,要否則無法解密 byte[] data = rsa.Decrypt(eCodes.Base64Decode(), false); Console.WriteLine(data.GetString()); // GetString 也是 Viyi.Util 中定義的擴展方法,默認用 UTF8 編碼
結果正如預期:
Hello World
如今,經過實驗,Web 前端使用 JSEncrypt 和 .NET 後端之間已經實現了 RSA 加/解密來完成安全的數據傳輸。其做法總結以下:
setKey()
導入公鑰,使用 encrypt()
加密字符串。加密前字符串會按 UTF8 編碼成二進制數據。特別須要注意的一點是:無論以何種方式(XML、PEM 等)將公鑰傳送給前端的時候,都切記不要把私鑰給出去了。這尤爲容易發生在使用 .ToXmlString(true)
以後再直接把結果送給前端。不要問我爲何會有這麼個提醒,要問就是由於……我見過!
還沒完呢,前面說過要補充 NodeJS 後端的狀況。NodeJS 關於加/解密的 SDK 都在 crypto
模塊中,
generateKeyPair()
或 generateKeyPairSync()
來產生密鑰對privateDecrypt()
來解密數據generateKeyPair()
是異步操做。如今 Node 中異步函數很常見,尤爲是寫 Web 服務端的時候,處處都是異步。不喜歡回調方式的話,可使用util
模塊中的promisify()
把它轉換一下。
// JavaScript Code, in Node environtment import { promisify } from "util"; import crypto from "crypto"; const asyncGenerateKeyPair = promisify(crypto.generateKeyPair); (async () => { const { publicKey, privateKey } = await asyncGenerateKeyPair( "rsa", { modulusLength: 1024, publicKeyEncoding: { type: "spki", format: "pem", }, privateKeyEncoding: { type: "pkcs1", format: "pem" } } ); console.log(publicKey) console.log(privateKey); })();
generateKeyPair
第 1 個參數是算法,很明顯。第 2 個參數是選項,強度 1024 也很明顯。只有 publicKeyEncoding
和 privateKeyEncoding
須要稍微解釋一下 —— 其實文檔也說得很明白:參考 keyObject.export()
。
對於公鑰,type
可選 "pkcs1"
或者 "spki"
,以前已經試過,JSEncrypt 只認 "spki"
,因此沒得選。
對於私鑰,RSA 只能選 "pkcs1"
,因此仍是沒得選。
不過 NodeJS 的 PEM 輸出要規範得多,看(一樣省略了中間部分):
-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCYur0zYBtqOqs98l4rh1J2olBb ... ... ... 8I8y4j9dZw05HD3u7QIDAQAB -----END PUBLIC KEY----- -----BEGIN RSA PRIVATE KEY----- MIICXAIBAAKBgQCYur0zYBtqOqs98l4rh1J2olBbYpm5n6aNonWJ6y59smqipfj5 ... ... ... UJKGwVN8328z40R5w0iXqtYNvEhRtYGl0pTBP1FjJKg= -----END RSA PRIVATE KEY-----
不論是否含標頭/標尾,也不論是不是有折行,JSEncrypt 都認,因此倒不用太在乎這些細節。總之 JSEncrypt 拿到公鑰以後仍是跟以前同樣,作一樣的事情,邏輯代碼一個字都不用改。
而後回到 NodeJS 解密:
// JavaScript Code, in Node environtment import crypto from "crypto"; const eCodes = "ZkhFRnigoHt...wXQX4="; // 做爲示例,偷個懶就用以前的那一段了 const buffer = crypto.privateDecrypt( { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(eCodes, "base64") ); console.log(buffer.toString());
privateDecrypt()
第 1 個參數給私鑰,能夠是以前導出的私鑰 PEM,也能夠是沒導出的 KeyObject
對象。須要注意的是必需要指定填充方式是 RSA_PKCS1_PADDING
,由於文檔說默認使用 RSA_PKCS1_OAEP_PADDING
。
還有一點須要注意的是別忘了 Buffer.from(..., "base64")
。
解密的結果是保存在 Buffer 中的,直接 toString()
轉成字符串就好,顯示指定 UTF-8,用 toString("utf-8")
固然也是能夠的。
Java 也大同小異,不過說實在,代碼量要大很多。爲了幹這些事情,大概須要導入這麼些類:
// Java Code import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; import java.util.Base64.Decoder; import java.util.Base64.Encoder; import javax.crypto.Cipher;
而後是產生密鑰對
// Java Code KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); gen.initialize(1024); KeyPair pair = gen.generateKeyPair(); Encoder base64Encoder = Base64.getEncoder(); String publicKey = base64Encoder.encodeToString(pair.getPublic().getEncoded()); String privateKey = base64Encoder.encodeToString(pair.getPrivate().getEncoded()); // 這裏輸出 PKCS#8,因此解密時須要用 PKCS8EncodedKeySpec System.out.println(pair.getPrivate().getFormat());
產生的 publicKey
和 privateKey
都是純純的 Base64,沒有其餘內容(沒有標頭/標尾等)。
而後是解密過程……
// Java Code String eCode = "k7M0hD....qvdk="; // 再次聲明,這是僅爲演示寫的閹割版數據 Decoder base64Decoder = Base64.getDecoder(); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(base64Decoder.decode(privateKey)); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm()); cipher.init(Cipher.DECRYPT_MODE, keyFactory.generatePrivate(keySpec)); byte[] data = cipher.doFinal(base64Decoder.decode(eCode)); System.out.println(new String(data, StandardCharsets.UTF_8));
寫完 Java 是真累,因此,之後的後端示例就用 NodeJS 了 —— 不是 Java 的鍋,主要是不想切環境。
下節看點:「註冊」的 DEMO,安全傳輸和保存用戶密碼。