概述
程式設計開發中,像使用者登入註冊這種功能很常見,那麼對於使用者密碼處理,我們該選擇什麼樣的加密演算法呢?在這種場景下,演算法需要滿足下面兩個條件:
- 演算法需不可逆,這樣才能有效防止密碼洩露。
- 演算法需相對慢,可以動態調整計算成本,緩慢是應對暴力破解有效方式。
目前來看有這麼幾個演算法 PBKDF2、 BCrypt 和 SCrypt 可以滿足。我們先看下舊的密碼加密方式。
舊的加密
過去密碼加密常用MD5或者SHA。MD5是早期設計的加密雜湊,它生成雜湊速度很快,隨著計算機能力的增強,出現了被破解的情況,所以又有了一些長度增大的雜湊函式,如:SHA-1,SHA-256等。下面是它們的一些比較:
- MD5:速度快生成短雜湊(16 位元組)。意外碰撞的概率約為:\( 1.47 \times 10^{-29} \) 。
- SHA1:比 md5 慢 20%,生成的雜湊比 MD5 長一點(20 位元組)。意外碰撞的概率約為:\( 1 \times 10^{-45} \) 。
- SHA256:最慢,通常比 md5 慢 60%,並且生成的雜湊長(32 位元組)。意外碰撞的概率約為: \( 4.3 \times 10^{-60} \) 。
為了確保安全你可能會選擇目前長度最長的雜湊SHA-512,但硬體能力在增強,或許有一天又會發現新的漏洞,研究人員又推出較新的版本,新版本的長度也會越來越長,而且他們也可能會發布底層演算法,所以我們應該另外尋找更合適的演算法。
加鹽操作
密碼安全,除了要選擇足夠可靠的加密演算法外,輸入資料的強度也要提升,因為密碼是人設定的,其字元長度組合強度不可能一致,如果直接進行雜湊儲存往往會提升爆破的概率,這時我們需要加鹽。
加鹽是密碼學中經常提到的概念,其實就是隨機資料。下面是一個 java 中生成鹽的例子:
public static byte[] generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[16];
random.nextBytes(salt);
return salt;
}
SHA-512 加鹽雜湊密碼
public static String sha512(String rawPassword, byte[] salt) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-512");
// 加點鹽
md.update(salt);
return Hex.encodeHexString(md.digest(rawPassword.getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("Could not create hash", ex);
}
}
PBKDF2
PBKDF1和PBKDF2是一個金鑰派生函式,其作用就是根據指定的密碼短語生成加密金鑰。之前在 常見加密演算法 提到過。它雖然不是加密雜湊函式,但它仍然適用密碼儲存場景,因為它有足夠的安全性,PBKDF2 函式計算如下:
$$ DK = PBKDF2(PRF, Password, Salt, Iterations, HashWidth) $$
- \( PRF \) 是偽隨機函式兩個引數,輸出固定的長度(例如,HMAC);
- \( Password \) 是生成派生金鑰的主密碼;
- \( Salt \) 是加密鹽;
- \( Iterations \) 是迭代次數,次數越多;
- \( HashWidth \) 是派生金鑰的長度;
- \( DK \) 是生成的派生金鑰。
PRF(HMAC)大致迭代過程,第一次時將 Password 作為金鑰和Salt傳入,然後再將輸出結果作為輸入重複完成後面迭代。
HMAC:基於雜湊的訊息認證碼,可以使用共享金鑰提供身份驗證。比如HMAC-SHA256,輸入需要認證的訊息和金鑰進行計算,然後輸出sha256的雜湊值。
PBKDF2不同於MD和SHA雜湊函式,它通過增加迭代次數提升了破解難度,並且還可以根據情況進行配置,這使得它具有滑動計算成本。
對於MD5和SHA,攻擊者每秒可以猜測數十億個密碼。而使用 PBKDF2,攻擊者每秒只能進行幾千次猜測(或更少,取決於配置),所以它適用於抗擊暴力攻擊。
2021 年,OWASP 建議對 PBKDF2-HMAC-SHA256 使用 310000 次迭代,對 PBKDF2-HMAC-SHA512 使用 120000 次迭代
public static String pbkdf2Encode(String rawPassword, byte[] salt) {
try {
int iterations = 310000;
int hashWidth = 256;
PBEKeySpec spec = new PBEKeySpec(rawPassword.toCharArray(), salt, iterations, hashWidth);
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
return Base64.getEncoder().encodeToString(skf.generateSecret(spec).getEncoded());
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("Could not create hash", ex);
}
}
Bcrypt
簡介
bcrypt 是基於 eksblowfish 演算法設計的加密雜湊函式,它最大的特點是:可以動態調整工作因子(迭代次數)來調整計算速度,因此就算以後計算機能力不斷增加,它仍然可以抵抗暴力攻擊。
關於eksblowfish演算法,它是採用分組加密模式,並且支援動態設定金鑰計算成本(迭代次數)。演算法的詳細介紹可檢視下面文章:
https://www.usenix.org/legacy...
結構
bcrypt 函式輸入的密碼字串不超過 72 個位元組、包含演算法識別符號、一個計算成本和一個 16 位元組(128 位)的鹽值。通過輸入計算得到 24位元組(192位)雜湊,最終輸出格式如下:
$2a$12$DQoa2eT/aXFPgIoGwfllHuj4wEA3F71WWT7E/Trez331HGDUSRvXi
\__/\/ \____________________/\_____________________________/
Alg Cost Salt Hash
$2a$
: bcrypt 演算法識別符號或叫版本;12
: 工作因子 (2^12 表示 4096 次迭代)DQoa2eT/aXFPgIoGwfllHu
: base64 的鹽值;j4wEA3F71WWT7E/Trez331HGDUSRvXi
: 計算後的 Base64 雜湊值(24 位元組)。
bcrypt 版本
$2a$
: 規定雜湊字串必須是 UTF-8 編碼,必須包含空終止符。$2y$
: 該版本為修復 2011年6月 PHP 在 bcrypt 實現中的一個錯誤。$2b$
: 該版本為修復 2014年2月 OpenBSD 在 bcrypt 實現中的一個錯誤。
2014年2月 在 OpenBSD 的 bcrypt 實現中發現,它使用一個無符號的 8 位值來儲存密碼的長度。對於長度超過255位元組的密碼,密碼將在72或長度模256 中的較小者處被截斷,而不是被截斷為72位元組。例如:260 位元組的密碼將被截斷為4個位元組,而不是截斷為 72 個位元組。
實踐
bcrypt 關鍵在於設定合適的工作因子,理想的工作因子沒有特定的法則,主要還是取決於伺服器的效能和應用程式上的使用者數量,一般在安全性和應用效能之間權衡設定。
假如你的因子設定比較高,雖然可以保證攻擊者難以破解雜湊,但是登入驗證也會變慢,嚴重影響使用者體驗,而且也可能被攻擊者通過大量登入嘗試耗盡伺服器的 CPU 來執行拒絕服務攻擊。一般來說計算雜湊的時間不應該超過一秒。
我們使用 spring security BCryptPasswordEncoder
看下不同因子下產生雜湊的時間,我電腦配置如下:
處理器:2.2 GHz 四核Intel Core i7
記憶體:16 GB 1600 MHz DDR3
顯示卡:Intel Iris Pro 1536 MB
Map<Integer, BCryptPasswordEncoder> encoderMap = new LinkedHashMap<>();
for (int i = 8; i <= 21; i++) {
encoderMap.put(i, new BCryptPasswordEncoder(i));
}
String plainTextPassword = "huhdfJ*!4";
for (int i : encoderMap.keySet()) {
BCryptPasswordEncoder encoder = encoderMap.get(i);
long start = System.currentTimeMillis();
encoder.encode(plainTextPassword);
long end = System.currentTimeMillis();
System.out.println(String.format("bcrypt | cost: %d, time : %dms", i, end - start));
}
bcrypt | cost: 8, time : 39ms
bcrypt | cost: 9, time : 45ms
bcrypt | cost: 10, time : 89ms
bcrypt | cost: 11, time : 195ms
bcrypt | cost: 12, time : 376ms
bcrypt | cost: 13, time : 720ms
bcrypt | cost: 14, time : 1430ms
bcrypt | cost: 15, time : 2809ms
bcrypt | cost: 16, time : 5351ms
bcrypt | cost: 17, time : 10737ms
bcrypt | cost: 18, time : 21417ms
bcrypt | cost: 19, time : 43789ms
bcrypt | cost: 20, time : 88723ms
bcrypt | cost: 21, time : 176704ms
擬合得到以下公式:
$$ 10.3064 \cdot e^{0.696464 x} $$
BCryptPasswordEncoder
因子範圍在 4-31 ,預設是 10,我們根據公式推導一下 31時需要多長時間。
/**
* @param strength the log rounds to use, between 4 and 31
*/
public BCryptPasswordEncoder(int strength) {
this(strength, null);
}
$$ 10.3064 \cdot e^{0.696464(31)} = 24529665567.08815 $$
工作因子 31
時大概需要 284
天,所以我們知道使用 bcrypt 可以很容易的擴充套件雜湊計算過程以適應更快的硬體,為我們留出很大的迴旋餘地,以防止攻擊者從未來的技術改進中受益。
SCrypt
SCrypt 比上面提到的演算法出來較晚,是Colin Percival於 2009 年 3 月建立的基於密碼的金鑰派生函式。關於該演算法我們需要明白下面兩點:
- 該演算法專門設計用於通過需要大量記憶體來執行大規模自定義硬體攻擊,成本高昂。
- 它屬於金鑰派生函式和上面提到 PBKDF2 屬於同一類別。
Spring security 也實現該演算法 SCryptPasswordEncoder
,輸入引數如下:
- CpuCost: 演算法的 cpu 成本。 必須是大於 1 的 2 的冪。預設當前為 16,384 或 2^14)
- MemoryCost: 演算法的記憶體成本。預設當前為 8。
- Parallelization: 演算法的並行化當前預設為 1。請注意,該實現當前不利用並行化。
- KeyLength: 演算法的金鑰長度。 當前預設值為 32。
- SaltLength: 鹽長度。 當前預設值為 64。
不過也有人提到,並不建議在生產系統中使用它來儲存密碼,他的結論是首先 SCrypt 設計目的是金鑰派生函式而不是加密雜湊,另外它實現上也並不那麼完美。詳細可檢視下面文章。
https://blog.ircmaxell.com/20...
結論
我會推薦使用 bcrypt。為什麼是 bcrypt 呢?
密碼儲存這種場景下,將密碼雜湊處理是最好的方式,第一它本身就是加密雜湊函式,其次按照摩爾定律的定義,整合系統上每平方英寸的電晶體數量大約每 18 個月翻一番。在 2 年內,我們可以增加它的工作因子以適應任何變化。
當然這並不是說其它演算法不夠安全,你仍然可以選擇其它演算法。建議優先使用 bcrypt,其次是金鑰派生類(PBKDF2 和 SCrypt),最後是雜湊+鹽(SHA256(salt))。