网络世界里,为了避免服务器受到攻击而数据库被拖库,用户的明文密码不被泄露,密文不被撞库,一般会对密码进行单向不可逆加密——哈希。

常见的方式是:

哈希方式 加密密码
md5('123456') e10adc3949ba59abbe56e057f20f883e
md5('123456' . ($salt = 'salt')) 207acd61a3c1bd506d7e9a4535359f8a
sha1('123456') 40位密文
hash('sha256', '123456') 64位密文
hash('sha512', '123456') 128位密文

更多加密请见米扑博客:php 加密算法md5, sha1

 

密文越长,在相同机器上,进行撞库消耗的时间越长,相对越安全。

比较常见的哈希方式是 md5 + 盐,避免用户设置简单密码,不容易被轻松破解。

password_hash

本文推荐的是 password_hash() 函数,可以轻松对密码实现加盐加密,而且几乎不能破解。

$password = '123456';
var_dump(password_hash($password, PASSWORD_DEFAULT));


password_hash 生成的哈希长度是 PASSWORD_BCRYPT —— 60位,PASSWORD_DEFAULT —— 60位 ~ 255位。

PASSWORD_DEFAULT 取值跟 php 版本有关系,会等于其他值,但不影响使用。

每一次 password_hash 运行结果都不一样,因此需要使用 password_verify 函数进行验证

$password = '123456';
$hash = password_hash($password, PASSWORD_DEFAULT);
var_dump(password_verify($password, $hash));

 

password_hash 会把计算 hash 的所有参数都存储在 hash 结果中,可以使用 password_get_info 获取相关信息。

$password = '123456';
$hash = password_hash($password, PASSWORD_DEFAULT);
var_dump(password_get_info($hash));

输出

array(3) {
 ["algo"]=>
 int(1)
 ["algoName"]=>
 string(6) "bcrypt"
 ["options"]=>
 array(1) {
 ["cost"]=>
 int(10)
 }
}

注意:不包含 salt

可以看出当前版本的 PHP 使用 PASSWORD_DEFAULT 实际是使用 PASSWORD_BCRYPT

password_hash($password, $algo, $options) 的第三个参数 $options 支持设置至少 22 位的 salt。但仍然强烈推荐使用 PHP 默认生成的 salt,不要主动设置 salt。

当要更新加密算法和加密选项时,可以通过 password_needs_rehash 判断是否需要重新加密,下面的代码是一段官方示例

$options = array('cost' => 11);
// Verify stored hash against plain-text password
if (password_verify($password, $hash))
{
 // Check if a newer hashing algorithm is available
 // or the cost has changed
 if (password_needs_rehash($hash, PASSWORD_DEFAULT, $options))
 {
  // If so, create a new hash, and replace the old one
  $newHash = password_hash($password, PASSWORD_DEFAULT, $options);
 }
 // Log user in
}

password_needs_rehash 可以理解为比较 $algo $option 和 password_get_info($hash) 返回值。

 

password_hash 运算慢

password_hash 是出了名的运行慢,也就意味着在相同时间内,密码重试次数少,泄露风险降低。

$password = '123456';
var_dump(microtime(true));
var_dump(password_hash($password, PASSWORD_DEFAULT));
var_dump(microtime(true));
  
echo "\n";
  
var_dump(microtime(true));
var_dump(md5($password));
for ($i = 0; $i < 999; $i++)
{
 md5($password);
}
var_dump(microtime(true));

输出

float(1495594920.7034)
string(60) "$2y$10$9ZLvgzqmiZPEkYiIUchT6eUJqebekOAjFQO8/jW/Q6DMrmWNn0PDm"
float(1495594920.7818)
 
float(1495594920.7818)
string(32) "e10adc3949ba59abbe56e057f20f883e"
float(1495594920.7823)

password_hash 运行一次耗时 784 毫秒

md5 运行 1000 次耗时 5 毫秒。

这是一个非常粗略的比较,跟运行机器有关,但也可以看出 password_hash 运行确实非常慢

 

 

问题1:hash碰撞 

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值。 

 

问题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 

在这里我们所做的只是在每个密码前附加上一个干扰字符串(salt)后进行hash,

只要附加的字符串足够复杂,hash后的值肯定是在预建的彩虹表中找不到的。

不过现在还是不够安全,比如salt数据库表也被窃取了呢。 

 

问题3:高级彩虹表 

注意,彩虹表可能在窃取到干拢字符串(salt)后重头开始建立。干扰字符串一样也可能被和数据库一起被窃取,然后他们可以利用这个干扰字符串从头开始创建彩虹表

如“easypassword”的hash值可能在普通的彩虹表中存在,但是在新建的彩虹表里,“f#@V)Hu^%Hgfdseasypassword”的hash值也会存在。 

如何解决干扰字符串salt也被窃取呢? 

我们可以对每个用户使用唯一的干扰字符串,一个可用的方案就是使用用户在数据库中的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 
// ... 

这种方法就防止了我们受到彩虹表的危害,因为每一个密码都使用一个不同的字符串进行了干扰,攻击者需要创建和密码数量一样的彩虹表,这是很不切实际的。 

 

问题4:hash速度 

大部分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年!” 

例如可以使用 sha1、sha246、sha512 等hash算法代替md5,你可以自己实现这种方法: 

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!” 

 

问题5:总结整合 

根据以上的几点讨论,总结写了一个工具类: 

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 
	// ... 
} 

 

问题6:加密是否可用 

并不是所有系统都支持Blowfish加密算法,虽然它现在已经很普遍了,你可以用以下代码来检查你的系统是否支持: 

if (CRYPT_BLOWFISH == 1) { 
	echo "Yes"; 
} else { 
	echo "No"; 
} 

不过对于php5.3,你就不必担心这点了,因为它内置了这个算法的实现。 

 

结论 

通过这种方法加密的密码对于绝大多数Web应用程序来说已经足够安全了。

不过不要忘记你还是可以让用户使用安全强度更高的密码,比如要求最少位数,使用字母,数字和特殊字符混合密码等。

用户登录安全的方式还有短信、邮箱、第三方安全控件ukey等

 

推荐方案:

随机验证码 + 不可逆的hash慢加密(sha256) + 随机加盐(salt) + 短信验证

 

 

参考推荐

php 加密算法md5, sha1

PHP 对称加密AES算法

Python 常用加密算法 base64, md5, sha1

Python中Base64编码和解码

AES、DES、RSA三种典型加密算法

AES 加密算法的详细介绍与实现

PHP 使用cookie实现记住登录状态

PHP Session与Cookie详解

公钥,私钥,数字签名的通俗理解