介面層做資料加密應該算是老生常談的一件事了,業界用的比較多的,不外乎是對稱加密,非對稱加密以及兩者的結合。
對稱加密,比較有代表性的就是 AES,金鑰只有一個,使用者端和伺服器端都要進行儲存,但是對使用者端來說,比較容易洩露,需要定期進行更換。
非對稱加密,比較有代表性的就是 RSA,有公鑰和私鑰,正常是伺服器端生成,將私鑰保留在伺服器端,公鑰派發出去,然後是使用者端用公鑰進行加密,伺服器端用私鑰進行解密。相對於對稱加密來說,是安全了一些,但是加解密的速度會慢一些,如果要加密的內容還比較多,還要進行分段處理,比較麻煩。
非對稱加密 + 對稱加密,這個應該是用的比較多的一種,做了一個折中處理,用 RSA 的公鑰加密 AES 的金鑰,然後用 AES 去加密請求資料。
下面老黃就用幾個簡單的例子來演示一下非對稱加密這一塊。
這裡介紹的是純純的非對稱加密,還不是結合對稱加密的。
這種情況,如果真的使用,一般是會在登入介面,再細一點的話,就是密碼那個欄位的加密。
先來看看簡單的流程圖
後端 API 介面需要提供兩個介面
獲取公鑰
[HttpGet("req-pub")]
public IActionResult ReqPub([FromQuery] string appId)
{
if(string.IsNullOrWhiteSpace(appId)) return BadRequest("invalid param");
// 模擬從資料庫或快取中取資料
var publicKey = RSAKeyMapping.GetServerPublicKeyByAppId(appId);
if(string.IsNullOrWhiteSpace(publicKey)) return BadRequest("invalid appId");
return Ok(new { data = publicKey });
}
解密處理
[HttpPost]
public async Task<IActionResult> Post([FromHeader] string appId)
{
if (string.IsNullOrWhiteSpace(appId)) return BadRequest("invalid appId");
// 這裡本可以用引數接受,不過有一些網站的登陸介面是直接傳密文
// 所以這裡也演示一下這種方式
var data = await new StreamReader(Request.Body).ReadToEndAsync();
if (string.IsNullOrWhiteSpace(data)) return BadRequest("invalid param");
// 模擬從資料庫或快取中取資料
var rsaKey = RSAKeyMapping.GetByAppId(appId);
if (rsaKey == null) return BadRequest("invalid appId");
// 解密,正常解密後會是一個 JSON 字串,然後反序列化即可
var decData = EncryptProvider.RSADecrypt(
rsaKey.ServerPrivateKey,
Convert.FromBase64String(data),
RSAEncryptionPadding.Pkcs1,
true);
return Ok($"Hello, {System.Text.Encoding.UTF8.GetString(decData)}");
}
前端用最原生的 HTML + JavaScript 來演示,這裡需要用到 jsencrypt 和 crypto-js。
大致流程的話,開啟頁面就從伺服器端獲取到公鑰,點選按鈕,就會把文字方塊中的內容用公鑰去加密,然後呼叫伺服器端的解密介面。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSA sample</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.3/jquery.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jsencrypt/3.3.1/jsencrypt.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<script>
let appId = "appId-2";
let url = "http://localhost:7775";
$(function () {
// 獲取公鑰
getPublicKeyFromServer();
$("#btnSubmit").click(function () {
var encrypt = new JSEncrypt();
encrypt.setPublicKey(localStorage.getItem("spk"));
var encData = encrypt.encrypt($("#txtData").val());
sendBizReq(encData)
});
});
function getPublicKeyFromServer() {
$.get(url + "/com/req-pub?appId=" + appId, function (data, status) {
localStorage.setItem("spk", data.data)
});
}
function sendBizReq(data) {
$.ajax({
url: url + "/biz",
type: 'post',
// dataType: 'json',
data: data,
headers: {
'appId': appId,
'Content-Type': 'application/json'
},
success: function (res) {
console.log(res)
alert(res);
},
error: function (e) {
console.log(e)
}
});
}
</script>
</head>
<body>
<div>
<input type="text" id="txtData" />
<button id="btnSubmit">submit</button>
</div>
</body>
</html>
執行效果大致如下:
這個例子不算複雜,應該比較好理解。
下面再來看看非對稱加密 + 對稱解密的方式。
這兩種加密結合的,網上其實很多例子,不過每個實現都會有一些細微的差別。
這種相對來說,適用的場景就比較多了,基本都可以覆蓋。
同樣看看簡單的流程圖,再看如何實現。
後端 API 介面也是需要提供兩個介面,公鑰獲取和上面的是一樣的,變動的是解密這一塊,因為這裡還引入了 AES 。
[HttpPost]
public IActionResult Post([FromHeader] string appId, [FromBody] RequestDto dto)
{
if(string.IsNullOrWhiteSpace(appId)) return BadRequest("invalid appId");
// 這裡正常用實體接收,不從流讀取了
if (dto == null
|| string.IsNullOrWhiteSpace(dto.EP)
|| string.IsNullOrWhiteSpace(dto.EAK)) return BadRequest("invalid param");
// 模擬從資料庫或快取中取資料
var rsaKey = RSAKeyMapping.GetByAppId(appId);
if (rsaKey == null) return BadRequest("invalid appId");
// 解密使用者端傳過來的 AES 金鑰
var decAesKey = EncryptProvider.RSADecrypt(
rsaKey.PrivateKey,
Convert.FromBase64String(dto.EAK),
RSAEncryptionPadding.Pkcs1,
true);
// 根據解密的金鑰,進行 AES 解密
var decData = EncryptProvider.AESDecrypt(
dto.EP,
System.Text.Encoding.UTF8.GetString(decAesKey));
return Ok($"Hello, {decData}");
}
前端這一塊其實變動也不會大,主要是多了一步 AES 金鑰的生成和 AES 的加密。
$(function () {
getPublicKeyFromServer();
$("#btnSubmit").click(function () {
var encrypt = new JSEncrypt();
encrypt.setPublicKey(localStorage.getItem("spk"));
// 隨機生成 aes 的金鑰
var aesKey = getAesKey();
// 用公鑰去加密這個金鑰
var encAesKey = encrypt.encrypt(aesKey);
// 用 aes 的金鑰去加密資料
var encData = aesEncrypt($("#txtData").val(), aesKey);
sendBizReq(encData, encAesKey)
});
});
function sendBizReq(data, aesKey) {
$.ajax({
url: url + "/biz",
type: 'post',
// dataType: 'json',
data: JSON.stringify({ ep: data, eak: aesKey }),
headers: {
'appId': appId,
'Content-Type': 'application/json'
},
success: function (res) {
console.log(res)
alert(res);
},
error: function (e) {
console.log(e)
}
});
}
function getAesKey() {
var s = [];
var hexDigits = "0123456789abcdef";
for (var i = 0; i < 32; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
}
s[14] = "4";
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1);
s[8] = s[13] = s[18] = s[23];
var uuid = s.join("");
return uuid;
}
function aesEncrypt(data, key) {
var encryptedData = CryptoJS.AES.encrypt(
CryptoJS.enc.Utf8.parse(data),
CryptoJS.enc.Utf8.parse(key),
{
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
return encryptedData.toString();
}
效果已經出來了。
可能有朋友會問,前端生成的 AES 金鑰可信嗎?
畢竟有流傳類似這樣一句話 任何使用者端傳過來的資料都是不能直接信任的。
有這種顧慮也算正常。
這個時候就需要考慮伺服器端生成金鑰的方案了:
生成其實是一件小事,傳輸是一件比較核心的事。
首先考慮金鑰也是密文傳輸的,所以伺服器端和使用者端要同時擁有一對公鑰和私鑰。
伺服器端在向用戶端傳輸金鑰時,要用使用者端的公鑰進行加密,然後使用者端用自己私鑰進行解密獲得,這樣才能保證金鑰的「安全性」。
這種的話,互動邏輯會複雜一些。
除了 RSA 演演算法,後面可能還要嘗試一下國密演演算法中的 SM2。