正確使用AES對稱加密
經常我看到項目中有人使用了對稱加密算法,用來加密客戶或項目傳輸中的部分數據。但我注意到開發 人員由於不熟悉原理,或者簡單復制網上的代碼示例,有導致代碼存在安全風險。
我經常遇到的問題,有如下:
- 如使用了過時的加密算法(如DES)
- 設置了不安全的加密模式(ECB)
- 不正確地處理初始向量(IV)
對稱加密算法
算法 | 位長 | 建議 |
---|---|---|
RC4 | 40 | |
DES | 56 | |
3DES | 112 | |
AES | 128 | ✔ |
TL;DR:
RC4/DES/3DES都 不符合 加密/破解的安全性要求。
DES是56位加密,聽起來感覺3DES應該是168位,但實際上其有效加密位長只有112位。
其它更長的加密算法,如AES 192位/AES 256位也符合要求。
加密模式
TL;DR: 不要使用ECB。
ECB不需要初始向量(IV),這個“驚人”的發現常常讓開發簡單粗暴地設計為ECB。ECB的問題在於輸入和輸出存在非常明顯的關聯,攻擊者可以從輸出輕松地猜出輸入數據。
C#的AES算法默認模式為CBC,該算法沒有上述的安全問題,而且最為通用,可以使用該模式。
初始向量
TL;DR:
初始向量 必須 為完全隨機數,完全隨機數應該使用RandomNumberGenerator
進行加密。
回想這個問題,數據加密完后,該發送什么給接收方?僅數據?那么初始向量(IV)怎么辦?
大多數開發選擇的辦法是,寫一個固定的初始向量(IV)用於加密,然后解密時,也使用相同的初始向量。這樣就導致相同的輸入會產生相同的輸出。
為什么相同的輸入應該產生不同的輸出?因為根據歷史經驗,攻擊者可以獲取一些信息,知道某個確定輸入的含義。一旦再次捕獲到相同的加密數據,就能輕易破解。
所以,發送數據應該包含:版本+初始向量+數據。
面向字符串
加密是面向字節還是字符串?我認為應該面向字節。如果面向字符串,那么很多問題很難受到重視。
試着回答這個問題:
- 用戶的密碼是什么樣子的?
- 是長度為固定32位的HEX字符嗎?如
1C8F7B2C9759209C6ACC3C105D39BBAC
? - 還是用戶想輸入什么就輸入什么?如
My-Super-Str0ng-Password!!
?
我認為加密算法應該面向字節流/字節數據,而不是字符串。將字符串發送給客戶、放在JSON中進行端對端傳輸,是沒什么毛病的做法。但基於以下原因,我強烈建議加密/解密算法要基於字節數據:
- 避免密碼太長或太短的問題
- 來回轉換為字符串效率低下
- 字符串轉換為字節數組容易,其它數據序列化為字節數據也容易
我的加密/解密方法
// 代碼按原樣提供,可隨意使用,但不對其安全性作任何保證。
string Encrypt(string password, string purpose, byte[] plainBytes)
{
byte[] key = PasswordToKey(password, purpose);
using (var aes = Aes.Create())
{
aes.Key = key;
using (ICryptoTransform encryptor = aes.CreateEncryptor())
{
byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
byte[] packedBytes = Pack(
version: 1,
iv: aes.IV,
cipherBytes: cipherBytes);
return Base64UrlEncode(packedBytes);
}
}
}
byte[] Decrypt(string packedString, string password, string purpose)
{
byte[] key = PasswordToKey(password, purpose);
byte[] packedBytes = Base64UrlDecode(packedString);
(byte version, byte[] iv, byte[] cipherBytes) = Unpack(packedBytes);
using (var aes = Aes.Create())
{
using (ICryptoTransform decryptor = aes.CreateDecryptor(key, iv))
{
return decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
}
}
}
其中公共方法:
// 代碼按原樣提供,可隨意使用,但不對其安全性作任何保證。
byte[] PasswordToKey(string password, string purpose)
{
using (var hmac = new HMACMD5(Encoding.UTF8.GetBytes(purpose)))
{
return hmac.ComputeHash(Encoding.UTF8.GetBytes(password));
}
}
string Base64UrlEncode(byte[] bytes)
{
return Convert.ToBase64String(bytes)
.Replace("/", "_")
.Replace("+", "-")
.Replace("=", "");
}
byte[] Base64UrlDecode(string base64Url)
{
return Convert.FromBase64String(base64Url
.Replace("_", "/")
.Replace("-", "+"));
}
(byte version, byte[] iv, byte[] cipherBytes) Unpack(byte[] packedBytes)
{
if (packedBytes[0] == 1)
{
// version 1
return (1, packedBytes[1..1 + 16], packedBytes[1 + 16..]);
}
else
{
throw new NotImplementedException("unknown version");
}
}
byte[] Pack(byte version, byte[] iv, byte[] cipherBytes)
{
return new[] { version }.Concat(iv).Concat(cipherBytes).ToArray();
}
解釋:
- Base64UrlEncode/Decode:用於將字符串在Url上傳輸,將
+/=
轉換成:-_
- Pack/Unpack:將版本/初始向量/密文打包/解包
- PasswordToKey:將長度不一樣密碼,加上
purpose
,轉換為長度一樣的key
,其中改成HMACSHA256可以使用256位的AES算法。
測試代碼:
// 代碼按原樣提供,可隨意使用,但不對其安全性作任何保證。
string purpose = "這個算法是用來搞SSO的";
// 返回:AcfCe3AQcmNkeNThv-u09H_HyGKy_iRy-7uGiW0IZOHI
Encrypt("密碼here", purpose, Encoding.UTF8.GetBytes("Hello World"));
// 返回:Hello World
Encoding.UTF8.GetString(Decrypt("AcfCe3AQcmNkeNThv-u09H_HyGKy_iRy-7uGiW0IZOHI", "密碼here", purpose));