数据库如何正确保存密码
网站登录总是会涉及到密码的,关于密码最重要的应该就是怎样保护用户的密码。存放用户信息的数据库经常会遭到入侵。最好的办法就是对密码进行加密,即使在被拖库,请求被劫持的情况下也能保障用户的密码不被泄露。 常见的加密方式有md5,SHA,我们知道如果直接对密码进行散列,那么黑客可以队通过获得这个密码的散列值,然后查彩虹表获得用户的密码。所以直接对密码进行md5加密肯定是不够的。聪明的程序员想出了个办法,即使用户的密码很短,只要我在他的短密码后面加上一段很长的字符,再计算 md5 ,那反推出原始密码就变得非常困难了。加上的这段长字符,我们称为盐(Salt),通过这种方式加密的结果,我们称为 加盐 Hash 。比如:
md5(md5(password)+salt)
常用的哈希函数中,SHA-256、SHA-512 会比 md5 更安全,更难破解,出于更高安全性的考虑,会使用 SHA-512 代替 md5 。
SHA512(SHA512(password)+salt)
通过上面的加盐哈希运算,即使攻击者拿到了最终结果,也很难反推出原始的密码。不能反推,但可以正着推,假设攻击者将 salt 值也拿到了,那么他可以枚举遍历所有密码,加盐哈希,计算出一个结果对照表,从而破解出简单的密码。这就是通常所说的暴力破解。 关于加盐哈希的过程中经常会犯的错误: 最常见的错误就是在多次哈希加密中使用相同的盐值或者太短的盐值。 每次哈希加密都使用相同的盐值是很容易犯的一个错误,这个盐值要么被硬编码到程序里,要么只在第一次使用时随机获得。这样加盐的方式是做无用功,因为两个相同的密码依然会得到相同的哈希值。攻击者仍然可以使用反向查表法对每个值进行字典攻击,只需要把盐值应用到每个猜测的密码上再进行哈希即可。如果盐值被硬编码到某个流行的软件里,可以专门为这个软件制作查询表和彩虹表,那么破解它生成的哈希值就变得很简单了。 用户创建账户或每次修改密码时,都应该重新生成新的盐值进行加密。 如果盐值太短,攻击者可以构造一个查询表包含所有可能的盐值。以只有3个ASCII字符的盐值为例,一共有95x95x95=857,375种可能。这看起来很多,但是如果对于每个盐值查询表只包含1MB最常见的密码,那么总共只需要837GB的储存空间。一个不到100美元的1000GB硬盘就能解决问题。 同样地,用户名也不应该被用作盐值。尽管在一个网站中用户名是唯一的,但是它们是可预测的,并且经常重复用于其他服务中。攻击者可以针对常见用户名构建查询表,然后对用户名盐值哈希发起进攻。 为了使攻击者无法构造包含所有可能盐值的查询表,盐值必须足够长。一个好的做法是使用和哈希函数输出的字符串等长的盐值,比如SHA256算法的输出是256bits(32 bytes),那么盐值也至少应该是32个随机字节。 对于每个用户的每个密码,盐值都应该是独一无二的。每当有新用户注册或者修改密码,都应该使用新的盐值进行加密。并且这个盐值也应该足够长,使得有足够多的盐值以供加密。一个好的标准的是:盐值至少和哈希函数的输出一样长;盐值应该被储存和密码哈希一起储存在账户数据表中。 在Web程序中,永远在服务器端进行哈希加密 这并不是说你不应该在浏览器端进行加密,但是如果你这么做了,一定要在服务端再次加密。在浏览器中进行哈希加密是个好想法,不过实现的时候注意下面几点: • 客户端密码哈希并不能代替HTTPS(SSL/TLS)。如果浏览器和服务器之间的连接是不安全的,那么中间人攻击可以修改JavaScript代码,删除加密函数,从而获取用户密码。 • 有些浏览器不支持JavaScript,也有的用户禁用了浏览器的JavaScript功能。为了最好的兼容性,你的程序应该检测JavaScript是否可用,如果答案为否,需要在服务端模拟客户端的加密。 • 客户端哈希同样需要加盐,很显然的办法就是向服务器请求用户的盐值,但是不要这么做。因为这给了坏蛋一个机会,能够在不知道密码的情况下检测用户名是否有效。既然你已经在服务端对密码进行了加盐哈希,那么在客户端把用户名(或邮箱)加上网站特有的字符串(如域名)作为盐值是可行的。 让密码更难破解:慢哈希函数 加盐使攻击者无法采用特定的查询表和彩虹表快速破解大量哈希值,但是却不能阻止他们使用字典攻击或暴力攻击。高端的显卡(GPU)和定制的硬件可以每秒进行数十亿次哈希计算,因此这类攻击依然可以很高效。为了降低攻击者的效率,我们可以使用一种叫做密钥扩展的技术。 这种技术的思想就是把哈希函数变得很慢,于是即使有着超高性能的GPU或定制硬件,字典攻击和暴力攻击也会慢得让攻击者无法接受。最终的目标是把哈希函数的速度降到足以让攻击者望而却步,但造成的延迟又不至于引起用户的注意。比如:bcrypt 就是这样一个慢哈希函数:
bcrypt(SHA512(password),salt,cost)
通过调整 cost 参数,可以调整该函数慢到什么程度。假设让 bcrypt 计算一次需要 0.5 秒,遍历 6 位的简单密码,需要的时间为:((26 * 2 + 10)^6) / 2 秒,约 900 年。 好了,有了上面的基础,来看看我的最终解决方案: 上图里有很多细节,我分阶段来讲: 1. 协商密钥 基于非对称加密的密钥协商算法,可以在通信内容完全被公开的情况下,双方协商出一个只有双方才知道的密钥,然后使用该密钥进行对称加密传输数据。比如图中所用的 ECDH 密钥协商。 2. 请求 Salt
双方协商出一个密钥 SharedKey 之后,就可以使用 SharedKey 作为 AES 对称加密的密钥进行通信,客户端传给服务端自己的公钥 A ,以及加密了的用户ID(uid)。服务端从数据库中查找到该 uid 对于的 Salt1 和 Salt2 ,然后再加密返回给客户端。
注意,服务端保存的 Salt1 和 Salt2 最好和用户数据分开存储,存到其他服务器的数据库里,这样即使被 SQL 注入,想要获得 Salt1 和 Salt2 也会非常困难。
3. 验证密码
这是最重要的一步了。客户端拿到 Salt1 和 Salt2 之后,可以计算出两个加盐哈希:
SaltHash1 = bcrypt(SHA512(password), uid + salt1, 10)
SaltHash2 = SHA512(SaltHash1 + uid + salt2)
使用 SaltHash2 做为 AES 密钥,加密包括 uid,time,SaltHash1,RandKey 等内容传输给服务端:
Ticket = AES(SaltHash2, uid + time + SaltHash1 + RandKey)
AES(SharedKey, Ticket)
服务端使用 SharedKey 解密出 Ticket 之后,再从数据库中找到该 uid 对应的 SaltHash2 ,解密 Ticket ,得到 SaltHash1 ,使用 SaltHash1 重新计算 SaltHash2 看是否和数据库中的 SaltHash2 一致,从而验证密码是否正确。 校验两个哈希值是否相等时,使用时间恒定的比较函数,防止试探性攻击。 time 用于记录数据包发送的时间,用来防止录制回放攻击。
4. 加密传输
密码验证通过后,服务端生成一个随机的临时密钥 TempKey(使用安全的随机函数),并使用 RandKey 做为密钥,传输给客户端。之后双方的数据交互都通过 TempKey 作为 AES 密钥进行加密。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!