安全地在前後端之間傳輸數據 - 「1」技術預研

已經不是第一次寫這個主題了,最近有朋友拿 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 編碼的證書。ExportRSAPublicKeyExportSubjectPublicKeyInfo 這兩個方法的返回類型都是 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 導入時不會要求這麼嚴格,省了不少事。

剩下的就是將 pkcs1spki 傳遞給前端了。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 加/解密來完成安全的數據傳輸。其作法總結如下:

  1. 後端產生 RSA 密鑰對,保存備用。保存方式可根據實際情況選擇:內存、文件、數據庫、緩存服務等

  2. 後端以 SPKI 格式導出公鑰(別忘了 Base64 編碼),通過某種業務接口形式傳遞給前端,或由前端主動請求獲得(比如調用特定 API)

  3. 前端使用 JSEncrypt,通過 setKey() 導入公鑰,使用 encrypt() 加密字符串。加密前字符串會按 UTF8 編碼成二進制數據。

  4. 後端獲得前端加密後的數據(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 也很明顯。只有 publicKeyEncodingprivateKeyEncoding 需要稍微解釋一下 —— 其實文檔也說得很明白:參考 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());

產生的 publicKeyprivateKey 都是純純的 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源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章