如何生成安全的密碼 Hash:MD5, SHA, PBKDF2, BCrypt 示例
密碼 Hash 值的產生是將使用者所提供的密碼通過使用一定的演算法計算後得到的加密字元序列。在 Java 中提供很多被證明能有效保證密碼安全的 Hash 演算法實現,我將在這篇文章中討論其中的部分演算法。
需要注意的是,一旦生成密碼的 Hash 值並存儲在資料庫中後,你將不可能再把它轉換回密碼明文。只能每次使用者在登入到應用程式時,須重新生成 Hash 值與資料庫中的 Hash 值匹配來完成密碼的校驗。
簡單的密碼安全實現使用 MD5 演算法
MD5訊息摘要演算法(MD5 Message-Digest Algorithm)是一種廣泛使用的加密 Hash 函式,主要用於生成一個128bit(16byte) Hash 值。它的實現思路非常簡單且易懂,其最基本的思路是將可變長度的資料集對映為固定長度的資料集
在該文中,對於被加密的密碼明文被稱為“訊息(message)”,其加密後所生成的 Hash 值稱為 “訊息摘要(message digest)” 或簡稱 "摘要(digest)"。以下是 MD5 產生 Hash 值的程式碼示例:
public class SimpleMD5Example { public static void main(String[] args) { String passwordToHash = "password"; String generatedPassword = null; try { // Create MessageDigest instance for MD5 MessageDigest md = MessageDigest.getInstance("MD5"); //Add password bytes to digest md.update(passwordToHash.getBytes()); //Get the hash's bytes byte[] bytes = md.digest(); //This bytes[] has bytes in decimal format; //Convert it to hexadecimal format StringBuilder sb = new StringBuilder(); for(int i=0; i< bytes.length ;i++) { sb.append(Integer.toString((bytes[i] & 0xff) + 0x100, 16).substring(1)); } //Get complete hashed password in hex format generatedPassword = sb.toString(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } System.out.println(generatedPassword); } } Console output: f4dcc3b5aa765d61d8327deb882cf99
雖然 MD5 是一個廣為流傳的 Hash 演算法, 但它並不安全且所生成的 Hash 值也是相當的薄弱。它主要的優點在於生成速度快且易於實現。但是,這也意味著它是容易被暴力攻擊和字典攻擊。例如使用明文和 Hash 生成的彩虹表可以快速地搜尋已知 Hash 對應的原資料。
此外,MD5 並沒有避免 Hash 碰撞:這意味不同的密碼會導致生成相同的 Hash 值。
不過,如果你仍然需要使用 MD5,可以考慮為其加 salt 來進一步保證它的安全性。
使用 salt 讓生成的 MD5 更加安全
這裡需要注意的是,加 salt 並不是 MD5 所特有的, 你同樣可以把它應用在其它演算法中。所以,在這裡你只需關注它是如何應用而不是它與 MD5 的聯絡。
在 Wikipedia 上對 salt 的定義是通過一個單向函式獲取隨機資料來為密碼或口令新增一些額外的資料。更簡單的說法則是通過生成一些隨機的文字將其附加到密碼上來生成 Hash。
為 Hash 加 salt 的主要目的是用來防止預先被計算好的彩虹表攻擊。現在加 slat 好處是將原本一次比較變為多次比較從而減慢對密碼 Hash 值的猜測,否則對 Hash 密碼庫的破解效率將是非常之高。
重要的是:在 Java 中,我們總是需要使用 SecureRandom 來生成一個好的 salt 值,因此可以利用 SecureRandom 類所提供 “SHA1PRNG” 演算法來生成偽隨機數。程式碼如下所示:
private static String getSalt() throws NoSuchAlgorithmException { //Always use a SecureRandom generator SecureRandom sr = SecureRandom.getInstance("SHA1PRNG"); //Create array for salt byte[] salt = new byte[16]; //Get a random salt sr.nextBytes(salt); //return salt return salt.toString(); }
SHA1PRNG 演算法是基於 SHA-1 演算法實現且保密性較強的偽隨機數生成器。要注意的是,如果不為它提供隨機數種子,它將會通過真隨機數來生成一個種子(TRNG)。 譯註:關於 TRGN 和 PRGN。
接下來,看看為 MD5 Hash 加 slat 的程式碼示例:
public class SaltedMD5Example { public static void main(String[] args) throws NoSuchAlgorithmException, NoSuchProviderException { String passwordToHash = "password"; String salt = getSalt(); String securePassword = getSecurePassword(passwordToHash, salt); System.out.println(securePassword); //Prints 83ee5baeea20b6c21635e4ea67847f66 String regeneratedPassowrdToVerify = getSecurePassword(passwordToHash, salt); System.out.println(regeneratedPassowrdToVerify); //Prints 83ee5baeea20b6c21635e4ea67847f66 } private static String getSecurePassword(String passwordToHash, String salt) { String generatedPassword = null; try { // Create MessageDigest instance for MD5 MessageDigest md = MessageDigest.getInstance("MD5"); //Add password bytes to digest md.update(salt.getBytes()); //Get the hash's bytes byte[] bytes = md.digest(passwordToHash.getBytes()); //This bytes[] has bytes in decimal format; //Convert it to hexadecimal format StringBuilder sb = new StringBuilder(); for(int i=0; i< bytes.length ;i++) { sb.append(Integer.toString((bytes[i] & 0xff) + 0x100, 16).substring(1)); } //Get complete hashed password in hex format generatedPassword = sb.toString(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } return generatedPassword; } //Add salt private static String getSalt() throws NoSuchAlgorithmException, NoSuchProviderException { //Always use a SecureRandom generator SecureRandom sr = SecureRandom.getInstance("SHA1PRNG", "SUN"); //Create array for salt byte[] salt = new byte[16]; //Get a random salt sr.nextBytes(salt); //return salt return salt.toString(); } }
重要提示:請注意,現在你必須每一個密碼 Hash 儲存它 slat 值。因為當用戶登入系統,你必須使用最初生成的 slat 來再次生成 Hash 與儲存的 Hash 進行匹配。如果使用不同的 slat(生成隨機 slat)那麼生成的 Hash 將不同。
此外,你可能聽說過多次 Hash 和加 salt。通常就是指建立自定義的組合,如:
salt+password+salt => hash
實際上你沒必要這樣去做,因為這並不能幫助你進一步鞏固 Hash 的安全性。如果你需要更高的安全性,正確的做法應該去選擇一個更好的演算法。
中等的密碼安全實現使用SHA演算法
SHA(安全雜湊演算法)同樣是作為加密 Hash 函式家族中的一員。除了它生成的 Hash 安全性比 MD5 更強之外其它都與 MD5 非常類似。然而,它們生成的 Hash 並不總是唯一的,這意味著輸入兩個不同的值所獲的 Hash 卻是相同的。通常這種情況的發生我們稱之為“碰撞”。 不過,SHA 碰撞的機率小於 MD5。你甚至無需擔心碰撞的發生,因為這種情況非常罕見。
在 Java 中有提供有4種 SHA 演算法的實現,相對 MD5(128 bit hash) 它提供了以下長度的 Hash:
- SHA-1 (簡單實現 – 160 bits Hash)
- SHA-256 (強於 SHA-1 – 256 bits Hash)
- SHA-384 (強於 SHA-256 – 384 bits Hash)
- SHA-512 (強於 SHA-384 – 512 bits Hash)
通常越長的 Hash 越難破解,這是核心思想。
要獲取相應演算法實現,可通過引數的方式傳給 MessageDigest 來獲得例項。如下所示:
MessageDigest md = MessageDigest.getInstance("SHA-1"); //OR MessageDigest md = MessageDigest.getInstance("SHA-256");
下面通過測試程式來看 SHA 的應用:
package com.howtodoinjava.hashing.password.demo.sha; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; public class SHAExample { public static void main(String[] args) throws NoSuchAlgorithmException { String passwordToHash = "password"; String salt = getSalt(); String securePassword = get_SHA_1_SecurePassword(passwordToHash, salt); System.out.println(securePassword); securePassword = get_SHA_256_SecurePassword(passwordToHash, salt); System.out.println(securePassword); securePassword = get_SHA_384_SecurePassword(passwordToHash, salt); System.out.println(securePassword); securePassword = get_SHA_512_SecurePassword(passwordToHash, salt); System.out.println(securePassword); } private static String get_SHA_1_SecurePassword(String passwordToHash, String salt) { String generatedPassword = null; try { MessageDigest md = MessageDigest.getInstance("SHA-1"); md.update(salt.getBytes()); byte[] bytes = md.digest(passwordToHash.getBytes()); StringBuilder sb = new StringBuilder(); for(int i=0; i< bytes.length ;i++) { sb.append(Integer.toString((bytes[i] & 0xff) + 0x100, 16).substring(1)); } generatedPassword = sb.toString(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } return generatedPassword; } private static String get_SHA_256_SecurePassword(String passwordToHash, String salt) { //Use MessageDigest md = MessageDigest.getInstance("SHA-256"); } private static String get_SHA_384_SecurePassword(String passwordToHash, String salt) { //Use MessageDigest md = MessageDigest.getInstance("SHA-384"); } private static String get_SHA_512_SecurePassword(String passwordToHash, String salt) { //Use MessageDigest md = MessageDigest.getInstance("SHA-512"); } //Add salt private static String getSalt() throws NoSuchAlgorithmException { SecureRandom sr = SecureRandom.getInstance("SHA1PRNG"); byte[] salt = new byte[16]; sr.nextBytes(salt); return salt.toString(); } } Output: // SHA-1 e4c53afeaa7a08b1f27022abd443688c37981bc4 // SHA-256 adfd14a7a89b201bf6d99105b417287db6581d8aee989076bb7f86154e8f32 // SHA-384 bc5914fe3896ae8a2c43a4513f2a0d716974cc305733847e3d49e1ea52d1ca50e2a9d0ac192acd43facfb422bb5ace88 // SAH-512 b8f7af61994670d03d25d55cc9cd1cff8d57bb799c4b586891e112b197530c76744bcd7ef135b58d47d65a0bec221eb5d77793956cf2709dd012
如上程式碼所示,在使用 SHA 的同時同樣可以為其加 salt 來加強它的安全性。
較高密碼安全實現使用 PBKDF2WithHmacSHA1 演算法
到目前為止,我們已經瞭解如何為密碼生成安全的 Hash 值以及通過利用 salt 來加強它的安全性。但今天的問題是,硬體的速度已經遠遠超過任何使用字典或彩虹表進行的暴力攻擊,並且任何密碼都能被破解,只是使用時間多少的問題。
為了解決這個問題,主要想法是儘可能降低暴力攻擊速度來保證最小化的損失。我們下一個演算法同樣是基於這個概念。目標是使 Hash 函式足夠慢以妨礙攻擊,並對使用者來說仍然非常快且不會感到有明顯的延時。
要達到這個目的通常是使用某些 CPU 密集型演算法來實現,比如 PBKDF2, Bcrypt 或 Scrypt 。這些演算法採用 work factor(也稱之為 security factor)或迭代次數作為引數來確定 Hash 函式將變的有多慢,並且隨著日後計算能力的提高,可以逐步增大 work factor 來使之與計算能力達到平衡。
Java 中可通過 "PBKDF2WithHmacSHA1" 來實現"PBKDF2"演算法,下面程式碼是使用示例:
public static void main(String[] args) throws NoSuchAlgorithmException, InvalidKeySpecException { String originalPassword = "password"; String generatedSecuredPasswordHash = generateStorngPasswordHash(originalPassword); System.out.println(generatedSecuredPasswordHash); } private static String generateStorngPasswordHash(String password) throws NoSuchAlgorithmException, InvalidKeySpecException { int iterations = 1000; char[] chars = password.toCharArray(); byte[] salt = getSalt().getBytes(); PBEKeySpec spec = new PBEKeySpec(chars, salt, iterations, 64 * 8); SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); byte[] hash = skf.generateSecret(spec).getEncoded(); return iterations + ":" + toHex(salt) + ":" + toHex(hash); } private static String getSalt() throws NoSuchAlgorithmException { SecureRandom sr = SecureRandom.getInstance("SHA1PRNG"); byte[] salt = new byte[16]; sr.nextBytes(salt); return salt.toString(); } private static String toHex(byte[] array) throws NoSuchAlgorithmException { BigInteger bi = new BigInteger(1, array); String hex = bi.toString(16); int paddingLength = (array.length * 2) - hex.length(); if(paddingLength > 0) { return String.format("%0" +paddingLength + "d", 0) + hex; }else{ return hex; } } Output: :5b4240333032306164:f38d165fce8ce42f59d366139ef5d9e1ca1247f0e06e503ee1a611dd9ec40876bb5edb8409f5abe5504aab6628e70cfb3d3a18e99d70357d295002c3d0a308a0
接下來的是當重新回來時,需要提供登入密碼驗證的方法實現:
public static void main(String[] args) throws NoSuchAlgorithmException, InvalidKeySpecException { String originalPassword = "password"; String generatedSecuredPasswordHash = generateStorngPasswordHash(originalPassword); System.out.println(generatedSecuredPasswordHash); boolean matched = validatePassword("password", generatedSecuredPasswordHash); System.out.println(matched); matched = validatePassword("password1", generatedSecuredPasswordHash); System.out.println(matched); } private static boolean validatePassword(String originalPassword, String storedPassword) throws NoSuchAlgorithmException, InvalidKeySpecException { String[] parts = storedPassword.split(":"); int iterations = Integer.parseInt(parts[0]); byte[] salt = fromHex(parts[1]); byte[] hash = fromHex(parts[2]); PBEKeySpec spec = new PBEKeySpec(originalPassword.toCharArray(), salt, iterations, hash.length * 8); SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); byte[] testHash = skf.generateSecret(spec).getEncoded(); int diff = hash.length ^ testHash.length; for(int i = 0; i < hash.length && i < testHash.length; i++) { diff |= hash[i] ^ testHash[i]; } return diff == 0; } private static byte[] fromHex(String hex) throws NoSuchAlgorithmException { byte[] bytes = new byte[hex.length() / 2]; for(int i = 0; i<bytes.length ;i++) { bytes[i] = (byte)Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16); } return bytes; }
參考以上程式碼時需注意,如果發現任何問題請下載本文章結尾處附件中的程式碼。
更加安全的密碼實現使用 bcrypt 和 scrypt 演算法
bcrypt 的背後的思想與 PBKDF2 類似。只是 Java 中並沒有內建支援使攻擊者變慢的 bcrypt 演算法實現,但你仍然可以找到並下載它的原始碼。
下以是 bcrypt 的使用程式碼示例(其中 Bcrypt.java 已提供在原始碼中):
public class BcryptHashingExample { public static void main(String[] args) throws NoSuchAlgorithmException { String originalPassword = "password"; String generatedSecuredPasswordHash = BCrypt.hashpw(originalPassword, BCrypt.gensalt(12)); System.out.println(generatedSecuredPasswordHash); boolean matched = BCrypt.checkpw(originalPassword, generatedSecuredPasswordHash); System.out.println(matched); } } Output: $2a$12$WXItscQ/FDbLKU4mO58jxu3Tx/mueaS8En3M6QOVZIZLaGdWrS.pK true
與 bcrypt 演算法類似,我已經從 github 下載了 scrypt 演算法的原始碼並新增到在最後一節中的原始碼下載中。看看它是如何使用:
public class ScryptPasswordHashingDemo { public static void main(String[] args) { String originalPassword = "password"; String generatedSecuredPasswordHash = SCryptUtil.scrypt(originalPassword, 16, 16, 16); System.out.println(generatedSecuredPasswordHash); boolean matched = SCryptUtil.check("password", generatedSecuredPasswordHash); System.out.println(matched); matched = SCryptUtil.check("passwordno", generatedSecuredPasswordHash); System.out.println(matched); } } Output: $s0$41010$Gxbn9LQ4I+fZ/kt0glnZgQ==$X+dRy9oLJz1JaNm1xscUl7EmUFHIILT1ktYB5DQ3fZs= true false
最後說明
- 在應用程式中儲存密碼明文是極其危險的事情。
- MD5 提供了最基本的安全 Hash 生成,使用時應為其新增 slat 來進一步加強它的安全性。
- MD5 生成128位的 Hash。為了使它更安全,應該使用 SHA 演算法生成 160-bit 或 512-bit 的長 Hash,其中 512-bit 是最強的。
- 雖然使用 SHA Hash 密碼也能被當今快速的硬體破解,如要避免這一點,你需要的演算法是能讓暴力攻擊儘可能的變慢且使影響減至最低。這時候你可以使用 PBKDF2, BCrypt 或 SCrypt 演算法。
- 請在深思熟慮後來選擇適當的安全演算法。
參考資料:
- https://en.wikipedia.org/wiki/MD5
- https://en.wikipedia.org/wiki/Secure_Hash_Algorithm
- http://en.wikipedia.org/wiki/Bcrypt
- http://en.wikipedia.org/wiki/Scrypt
- http://en.wikipedia.org/wiki/PBKDF2
- https://github.com/wg/scrypt
- http://www.mindrot.org/projects/jBCrypt/