引
已經不是第一次寫這個主題了,最近有朋友拿 5 年前的《Web 應用中保證密碼傳輸安全》來問我:“爲什麼按你說的一步步做下來,後端解不出來呢?”加解密這種事情,差之毫釐謬以千里,我認爲多半就是什麼參數沒整對,仔細查查改對了就行。代碼拿來一看,傻眼了……沒毛病啊,爲啥解不出來呢?
時間久遠,原文附帶的源代碼已經下不下來了。翻閱各種參考鏈接的時候從 CodeProject 上找了個代碼,把各參數換過去一試,沒毛病呀!這可奇了怪了,於是去 RSA.js 的文檔(沒有專門的文檔,就是文檔註釋)中查,發現 RSA.js 在 2014 年 1 月加入了 Padding 參數,《Web 應用中保證密碼傳輸安全》雖然是 2014 年 2 月寫的,但可能陰差陽錯用到了老版本。
不就是 Padding 嗎,文檔也懶得看了,前後端都指定 PKCS1Padding 試試。失敗!
那暴力一點,所有 Padding 都試試!
前端使用 RSA.js 在 RSAAPP
中定義的 4 種 Padding,後端 C# 使用 RSAEncryptionPadding
中定義的 5 種 Padding,組合了 20 種情況,逐一試驗……好吧,沒一個對的!
世界上這麼多樹,何必非要在這一棵上吊死,何況它還沒有發佈到 npm …… 理由找夠了,咱就換!
網上搜了一圈之後,選擇了 JSEncrypt 這個庫。
核心知識
在講 JSEncrypt 之前,咱們回到“安全傳輸”這一主題。這一主題的關鍵技術在於加解密,說起加解密,那就是三大類算法:HASH(摘要)算法、對稱加密算法和非對稱加密算法。基本的安全傳輸過程可以用一張圖來 展示:
不過這只是最基本的安全傳輸理論,實際上,證書(公鑰)分發等方面仍然存在安全隱患,所以纔會有CA、纔會有受信根證書……不過這裏不作延展,只給個結論:在 Web 前後端傳輸這個問題上,HTTPS 就是最佳實踐,是首選 Web 傳輸解決方案,只有在不能使用 HTTPS 的情況,才退而求其次,用自己的實現來提高一點安全門檻。
JSEncrypt
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# 產生密鑰並導出
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 導入公鑰並加密
JSEncrypt 提供了 setPublicKey()
和 setPrivateKey()
來導入密鑰。不過文檔中提到它們其實都是 setKey()
的別名,這點需要注意一下。爲了避免語義不清,我建議直接使用 setKey()
。
You can use also setPrivateKey and setPublicKey, they are both alias to setKey
from: http://travistidwell.com/jsencrypt/
那麼導入公鑰並試驗加密的過程大概會是這樣:
// 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# 中驗證這個密文是可以解出來的。
C# 驗證可以解密 JSEncrypt 生成的密文
上面生成的那一段 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 加/解密來完成安全的數據傳輸。其作法總結如下:
後端產生 RSA 密鑰對,保存備用。保存方式可根據實際情況選擇:內存、文件、數據庫、緩存服務等
後端以 SPKI 格式導出公鑰(別忘了 Base64 編碼),通過某種業務接口形式傳遞給前端,或由前端主動請求獲得(比如調用特定 API)
前端使用 JSEncrypt,通過
setKey()
導入公鑰,使用encrypt()
加密字符串。加密前字符串會按 UTF8 編碼成二進制數據。後端獲得前端加密後的數據(Base64 編碼)後,解密成二進制數據,並使用 UTF8 解碼成文本。
特別需要注意的一點是:不管以何種方式(XML、PEM 等)將公鑰傳送給前端的時候,都切記不要把私鑰給出去了。這尤其容易發生在使用 .ToXmlString(true)
之後再直接把結果送給前端。不要問我爲什麼會有這麼個提醒,要問就是因爲……我見過!
關門放 Node
還沒完呢,前面說過要補充 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 也大同小異,不過說實在,代碼量要大不少。爲了幹這些事情,大概需要導入這麼些類:
// 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,安全傳輸和保存用戶密碼。
本文分享自微信公衆號 - 邊城客棧(fancyidea-full)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。