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

已經不是第一次寫這個主題了,最近有朋友拿 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

image.png

不過這只是最基本的安全傳輸理論,實際上,證書(公鑰)分發等方面仍然存在安全隱患,因此纔會有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/jsen...

那麼導入公鑰並試驗加密的過程大概會是這樣:

// 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,安全傳輸和保存用戶密碼。

相關文章
相關標籤/搜索