1.聲明
密碼學是一個複雜的話題,我也不是這方面的專家。許多高校和研究機構在這方面都有長期的研究。在這篇文章裡,我希望盡量使用簡單易懂的方式向你展示一種安全儲存Web程式密碼的方法。
2.“Hash”是做什麼的?
“Hash將一段資料(小資料或大資料)轉換成一段相對短小的資料,如字串或整數。”
這是依靠單向hash函數來完成的。所謂單向是指很難(或者是實際上不可能)將其反轉回來。一個常見的hash函數的例子是md5(),它流行於各種電腦語言和系統。
複製代碼 代碼如下:$data = "Hello World";
$hash = md5($data);
echo $hash; // b10a8db164e0754105b7a99be72e3fe5
使用md5()運算出來的結果總是32個字元的字串,不過它只包含16進位的字元,從技術上來說它也可以用128位(16位元組)的整形數來表示。你可以使用md5()來處理很長的字串和資料,但是你始終得到的是一個固定長度的hash值,這也可能可以協助你理解為什麼這個函數是“單向”的。
3.使用Hash函數來儲存密碼
典型的使用者註冊過程:
使用者填寫註冊表單,其中包含密碼欄位;
程式將所有使用者填寫的資訊儲存到資料庫中;
然而密碼在儲存到資料庫前通過hash函數加密處理;
原始的密碼不再儲存在任何地方,或者說它被丟棄了。
使用者登入過程:
使用者輸入使用者名稱和密碼;
程式將密碼通過以註冊相同的hash函數進行加密;
程式從資料庫查到使用者,並讀取hash後的密碼;
程式比較使用者名稱和密碼,如果匹配則給使用者授權。
如何選擇合適的方法來加密密碼,我們將在文章的後面討論這個問題。
4.問題1:hash碰撞
hash碰撞是指對兩個不同的內容進行hash得到了相同的hash值。發生hash碰撞的可能性取決於所用的hash演算法。
如何產生?
舉個例子,一些老式程式使用crc32()來hash密碼,這種演算法產生一個32位的整數作為hash結果,這意味著只有2^32 (即4,294,967,296) 種可能的輸出結果。
讓我們來hash一個密碼: 複製代碼 代碼如下:echo crc32('supersecretpassword');
// outputs: 323322056
現在我們假設一個人竊取了資料庫,得到了hash過的密碼。他可能不能將323322056還原為‘supersecretpassword',然而他可以找到另一個密碼,也能被hash出同樣的值。這隻需要一個很簡單的程式: 複製代碼 代碼如下:set_time_limit(0);
$i = 0;
while (true) {
if (crc32(base64_encode($i)) == 323322056) {
echo base64_encode($i);
exit;
}
$i++;
}
這個程式可能需要運行一段時間,但是最終它能返回一個字串。我們可以使用這個字串來代替‘supersecretpassword',並使用它成功的登入使用該密碼的使用者帳戶。
比如在我的電腦上運行上面的程式幾個月後,我得到了一個字串:‘MTIxMjY5MTAwNg=='。我們來測試一下: 複製代碼 代碼如下:echo crc32('supersecretpassword');
// outputs: 323322056
echo crc32('MTIxMjY5MTAwNg==');
// outputs: 323322056
如何解決?
現在一個稍強一點的家用PC機就可以一秒鐘運行十億次hash函數,所以我們需要一個能產生更大範圍的結果的hash函數。比如md5()就更合適一些,它可以產生128位的hash值,也就是有340,282,366,920,938,463,463,374,607,431,768,211,456種可能的 輸出。所以人們一般不可能做那麼多次迴圈來找到hash碰撞。然而仍然有人找到方法來做這件事情,詳細可以查看例子。
sha1()是一個更好的替代方案,因為它產生長達160位的hash值。
5.問題2:彩虹表
即使我們解決了碰撞問題,還是不夠安全。
“彩虹表通過計算常用的詞及它們的組合的hash值建立起來的表。”
這個表可能儲存了幾百萬甚至十億條資料。現在儲存已經非常的便宜,所以可以建立非常大的彩虹表。
現在我們假設一個人竊取了資料庫,得到了幾百萬個hash過的密碼。竊取者可以很容易地一個一個地在彩虹表中尋找這些hash值,並得到原始密碼。雖然不是所有的hash值都能在彩虹表中找到,但是肯定會有能找到的。
如何解決?
我們可以嘗試給密碼加點幹擾,比如下面的例子: 複製代碼 代碼如下:$password = "easypassword";
// this may be found in a rainbow table
// because the password contains 2 common words
echo sha1($password); // 6c94d3b42518febd4ad747801d50a8972022f956
// use bunch of random characters, and it can be longer than this
$salt = "f#@V)Hu^%Hgfds";
// this will NOT be found in any pre-built rainbow table
echo sha1($salt . $password); // cd56a16759623378628c0d9336af69b74d9d71a5
在這裡我們所做的只是在每個密碼前附加上一個幹擾字串後進行hash,只要附加的字串足夠複雜,hash後的值肯定是在預建的彩虹表中找不到的。不過現在還是不夠安全。
6.問題3:還是彩虹表
注意,彩虹表可能在竊取到幹攏字串後重頭開始建立。幹擾字串一樣也可能被和資料庫一起被竊取,然後他們可以利用這個幹擾字串從頭開始建立彩虹表,如“easypassword”的hash值可能在普通的彩虹表中存在,但是在建立的彩虹表裡,“f#@V)Hu^%Hgfdseasypassword”的hash值也會存在。
如何解決?
我們可以對每個使用者使用唯一的幹擾字串。一個可用的方案就是使用使用者在資料庫中的id: 複製代碼 代碼如下:$hash = sha1($user_id . $password);
這種方法的前提是使用者的id是一個不變的值(一般應用都是這樣的)
我們也可以為每個使用者隨機產生一串唯一的幹擾字串,不過我們也需要將這個串儲存起來: 複製代碼 代碼如下:// generates a 22 character long random string
function unique_salt() {
return substr(sha1(mt_rand()),0,22);
}
$unique_salt = unique_salt();
$hash = sha1($unique_salt . $password);
// and save the $unique_salt with the user record
// ...
這種方法就防止了我們受到彩虹表的危害,因為每一個密碼都使用一個不同的字串進行了幹擾。攻擊者需要建立和密碼數量一樣的彩虹表,這是很不切實際的。
7.問題4:hash速度
大部分hash演算法在設計時就考慮了速度問題,因為它一般用來計算大資料或檔案的hash值,以驗證資料的正確性和完整性。
如何產生?
如前所述,現在一台強勁的PC機可以一秒運算數十億次,很容易用暴力破解法去嘗試每個密碼。你可能會以為8個以上字元的密碼就可以避免被暴力破解了,但是讓我們來看看是否真是這樣:
如果密碼可以包含小寫字母,大寫字母和數字,那就有62(26+26+10)個字元可選;
一個8位的密碼有62^8種可能組合,這個數字略大於218萬億。
以一秒鐘運算10億次hash值的速度計算,這隻需要60小時就可以解決。
對於一個6位的密碼,也是很常用的密碼,只需要1分鐘就可以破解。要求9到10位的密碼可能會比較安全了,不過這樣有的使用者可能會覺得很麻煩。
如何解決?
使用慢一點的hash函數。
“假設你使用一個在相同硬體條件下一秒鐘只能運行100萬次的演算法來代替一秒10億次的演算法,那麼攻擊者可能需要要花1000倍的時間來做暴力破解,60小隻將會變成7年!”
你可以自己實現這種方法: 複製代碼 代碼如下:function myhash($password, $unique_salt) {
$salt = "f#@V)Hu^%Hgfds";
$hash = sha1($unique_salt . $password);
// make it take 1000 times longer
for ($i = 0; $i < 1000; $i++) {
$hash = sha1($hash);
}
return $hash;
}
你也可以使用一個支援“成本參數”的演算法,比如 BLOWFISH。在php中可以用crypt()函數實現: 複製代碼 代碼如下:function myhash($password, $unique_salt) {
// the salt for blowfish should be 22 characters long
return crypt($password, '$2a$10.$unique_salt');
}
這個函數的第二個參數包含了由”$”符號分隔的幾個值。第一個值是“$2a”,指明應該使用BLOWFISH演算法。第二個參數“$10”在這裡就是成本參數,這是以2為底的對數,指示計算迴圈迭代的次數(10 => 2^10 = 1024),取值可以從04到31。
舉個例子: 複製代碼 代碼如下:function myhash($password, $unique_salt) {
return crypt($password, '$2a$10.$unique_salt');
}
function unique_salt() {
return substr(sha1(mt_rand()),0,22);
}
$password = "verysecret";
echo myhash($password, unique_salt());
// result: $2a$10$dfda807d832b094184faeu1elwhtR2Xhtuvs3R9J1nfRGBCudCCzC
結果的hash值包含$2a演算法,成本參數$10,以及一個我們使用的22位幹擾字串。剩下的就是計算出來的hash值,我們來運行一個測試程式: 複製代碼 代碼如下:// assume this was pulled from the database
$hash = '$2a$10$dfda807d832b094184faeu1elwhtR2Xhtuvs3R9J1nfRGBCudCCzC';
// assume this is the password the user entered to log back in
$password = "verysecret";
if (check_password($hash, $password)) {
echo "Access Granted!";
} else {
echo "Access Denied!";
}
function check_password($hash, $password) {
// first 29 characters include algorithm, cost and salt
// let's call it $full_salt
$full_salt = substr($hash, 0, 29);
// run the hash function on $password
$new_hash = crypt($password, $full_salt);
// returns true or false
return ($hash == $new_hash);
}
運行它,我們會看到”Access Granted!”
8.整合起來
根據以上的幾點討論,我們寫了一個工具類: 複製代碼 代碼如下:class PassHash {
// blowfish
private static $algo = '$2a';
// cost parameter
private static $cost = '$10';
// mainly for internal use
public static function unique_salt() {
return substr(sha1(mt_rand()),0,22);
}
// this will be used to generate a hash
public static function hash($password) {
return crypt($password,
self::$algo .
self::$cost .
'$'. self::unique_salt());
}
// this will be used to compare a password against a hash
public static function check_password($hash, $password) {
$full_salt = substr($hash, 0, 29);
$new_hash = crypt($password, $full_salt);
return ($hash == $new_hash);
}
}
以下是註冊時的用法: 複製代碼 代碼如下:// include the class
require ("PassHash.php");
// read all form input from $_POST
// ...
// do your regular form validation stuff
// ...
// hash the password
$pass_hash = PassHash::hash($_POST['password']);
// store all user info in the DB, excluding $_POST['password']
// store $pass_hash instead
// ...
以下是登入時的用法: 複製代碼 代碼如下:// include the class
require ("PassHash.php");
// read all form input from $_POST
// ...
// fetch the user record based on $_POST['username'] or similar
// ...
// check the password the user tried to login with
if (PassHash::check_password($user['pass_hash'], $_POST['password']) {
// grant access
// ...
} else {
// deny access
// ...
}
9.加密是否可用
並不是所有系統都支援Blowfish密碼編譯演算法,雖然它現在已經很普遍了,你可以用以下代碼來檢查你的系統是否支援: 複製代碼 代碼如下:if (CRYPT_BLOWFISH == 1) {
echo "Yes";
} else {
echo "No";
}
不過對於php5.3,你就不必擔心這點了,因為它內建了這個演算法的實現。
結論
通過這種方法加密的密碼對於絕大多數Web應用程式來說已經足夠安全了。不過不要忘記你還是可以讓使用者使用安全強度更高的密碼,比如要求最少位元,使用字母,數字和特殊字元混合密碼等。