不一定非要自己存储用户的密码
最简单的存储密码的方式就是自己并不存储,而是委托给信任的第三方存储。这就是OpenID技术,它的理念是用第三方来完成用户验证的操作。目前国外的网站如谷歌、雅虎等,国内的如腾讯等都已经提供OpenID的服务。如果我们开发一个网站并选择谷歌的OpenID服务,那么用户就可以用Gmail的账号和密码登录,接下来用户认证的事情将由谷歌完成。
采用OpenID技术无论是对网站的开发者还是用户,就具备明显的优点。由于用户的登录认证是由第三方的OpenID服务提供商完成的,我们自己没有必要存储用户名和密码,也就没有必要考虑存储密码的安全性问题,从而减少开发的成本。同时,用户不用在我们的网站上注册新的用户名和密码。这样既免去了用户在注册用户名和密码时填写资料的麻烦,也减去了用户需要记住一对新的用户名和密码的负担。
千万不要用明文存储密码
有很多公司由于种种原因不愿意把自己客户的登录信息保存在其他公司,于是不得不自己存储用户的密码。既然决定自己存储,就要考虑存储的安全性。密码存储的最低要求是不能用明文(没有经过加密)存储密码。如果用明文存储密码,一旦数据库泄露出去,所有用户的密码就毫无保留地暴露在黑客的面前,这可能给用户以及公司带来巨大的损失。
虽然不能用明文存储密码的道理显而易见,但实际上仍然有不少公司在采用这种极度不安全的方式。这一点从时不时爆出的各种网站密码泄露事件可以看出。对那些仍然在用明文存储密码的公司,我们只能奉劝他们尽早用哈希算法给密码加密之后再存储,别等到密码泄露之后造成重大损失才幡然大悟。
用哈希算法加密密码
常用的给密码加密的算法是几种单向的哈希算法。所谓的单向的算法是指我们只能从明文生成一个对应的哈希值,却不能反过来根据哈希值得到对应的明文。经常被大家用来加密的算法有MD5和SHA系列(如SHA1、SHA256、SHA384、SHA512等)。值得注意的是,MD5算法已经被中国数学家王小云破解,因此这种算法已经不建议在产品中使用。
虽然用哈希算法能提高密码存储的安全性,但还是不够安全。通常黑客在侵入保存密码的数据库之后,他会随机猜测一个密码,用哈希算法生成一个哈希值。如果该哈希值在数据库中存在,那么他就猜对了一个用户的密码。如果没有猜中也没有关系,他可以再次随机猜测下一个密码进行尝试。事实上黑客为了提高破解密码的效率,他们会事先计算大量密码对应的各种哈希算法的哈希值,并把密码及对应的哈希值存入一个表格中(这种表格通常被称为彩虹表)。在破解密码时只需要到事先准备的彩虹表里匹配即可。因此现在黑客们破解仅仅只用哈希算法加密过的密码事实上已是不费吹灰之力。
加盐提高安全性
为了应对黑客们用彩虹表破解密码,我们可以先往明文密码加盐,然后再对加盐之后的密码用哈希算法加密。所谓的盐是一个随机的字符串,往明文密码里加盐就是把明文密码和一个随机的字符串拼接在一起。由于盐在密码校验的时候还要用到,因此通常盐和密码的哈希值是存储在一起的。
采用加盐的哈希算法对密码加密,有一点值得注意。我们要确保要往每个密码里添加随机的唯一的盐,而不是让所有密码共享一样的盐。如果所有密码共享统一的盐,当黑客猜出了这个盐之后,他就可以针对这个盐生成一个彩虹表,再将我们加盐之后的哈希值到他的新彩虹表里去匹配就可以破解密码了。
虽然加盐的算法能有效应对彩虹表的破解法,但它的安全级别并不高,这是由于哈希算法的特性造成的。哈希算法最初是用来确保网络传输数据时的数据完整性。当我们通过网络传输一个数据包时,我们在发送时会在数据包的末尾附上这个数据包对应的哈希值。在接收数据时,我们再次根据接收到的数据包用同样的算法生成一个哈希值。如果这个哈希值和从网络上接收到的哈希值一样,那就证明了数据在传输时没有出现问题。为了减少网络传输的延时,我们希望哈希算法尽量的快,尽可能地减少数据校验的时间。因此在设计哈希算法的时候,快速高效是一个非常重要的指标。目前在普通配置的电脑上,主流的哈希算法的耗时在微秒的级别,这意味着我们可以在一秒之类计算哈希值近百万次。
快速高效的哈希算法给加密算法带来了不少的挑战,因为安全的加密算法应该是黑客极难破解的算法,而计算哈希值的耗时非常短,黑客们就可以用暴力法去破解加盐之后的用哈希算法加密的密码。前面提到,盐通常和哈希值存储在一起。于是黑客针对每一个盐可以采用两种简单的暴力法破解密码。
一是采用穷举法。黑客生成一个密码,和盐拼接在一起再计算哈希值。如果哈希值和数据库中的哈希值一致,那么这个密码就被破解。如果不一致,在进行下一次尝试。这种方法对低级别的密码非常有效。比如6位全是数字的密码总共只有一百万中可能。这意味着任何6位的纯数字密码即使加盐之后也能在数秒之内破解。虽然用暴力法破解高级别的密码(比如同时包含数字、大小写字母和特殊符号的密码)目前还需要大量的时间,但计算能力遵循着摩尔定律持续地提高,今天看来非常耗时的操作在今后可能非常快速地完成。另外,近年来云计算的快速发展,也使得黑客们能够用非常便宜的价格租到大量的计算机以方便他们并行地破解密码,从而黑客们低成本并且高效地破解高级别密码成为了可能。
二是黑客们从历次密码泄露事件中收集了大量的常用密码。针对每一个盐,黑客可以循环地从这些重用密码里挑选一个,加盐再计算哈希值。因此这些常用的密码即使加盐也很容易破解。
用BCrypt或者PBKDF2增加破解的难度
为了应对暴力破解法,我们需要非常耗时的而不是非常高效的哈希算法。BCrypt算法应运而生。我们可以用BCrypt算法加盐之后给密码生成一个哈希值。Bcrypt最大的特点是我们可以通过参数设置重复计算的次数。显而易见重复计算的次数越多耗时越长。如果计算一个哈希值需要耗时1秒甚至更多,那么黑客们采用暴利法破解密码将不再可能。以前面提到的6位纯数字密码为例,破解一个密码需要耗时11.5天,更不要说高安全级别的密码了。
目前已有开源项目(http://bcrypt.sourceforge.net/)实现了BCrypt算法并被业界广泛采用。如果你是一个.NET程序员,你可能会发现目前的.NETFramework中还没有包含BCrypt的实现。此时有两个选择。一是已经有一些开源项目用C#实现了BCrypt算法,我们可以直接使用。如果对这些实现的安全性存在担忧,我们也可以选择和BCrypt类似的PBKDF2。PBKDF2同样也可以通过参数设定重复计算的次数从而延长计算时间。在.NETFramework中,类型Rfc2898DeriveBytes实现了PBKDF2的功能。
小结
安全地存储密码不是一件容易的事情。虽然目前有很多公司采用加盐的哈希算法应对彩虹表破解法,但这种方法并不是足够安全的。由于哈希算法非常高效,计算哈希值耗时在微秒级别,因此黑客可以通过暴力法破解密码。一个推荐的办法用BCrypt或者PBKDF2延长计算哈希值的时间,从而提高破解密码的难度。另外,并不是每个公司、项目都需要自己存储密码。我们的另一个选择是用OpenID把用户校验工作委托给可以信赖的第三方公司。
1、绝不要以明文存储密码。
2、永远使用【哈希函数】来处理密码。
3、绝不使用 base64 或 其他编码方式来存储密码。
编码和加密都是双向的过程,而密码是保密的,应该只被它的所有者知道,这个过程必须是单向的。编码存在解码,加密存在解密。
4、绝不使用弱哈希或已被破解的哈希算法,像md5 或 sha1。
5、绝不用自己发明的算法。
6、只使用强密码哈希算法,如BCrypt
二、Chrome浏览器
获得密码难易程度:简单
我们从Chrome浏览器开始。令人失望的是,chrome浏览器是最容易被提取密码的。加密后的密钥存储于%APPDATA%..\Local\Google\Chrome\User Data\Default\Login Data”下的一个SQLite数据库中。但是是如何获转存并加密的呢?我从《谷歌Chrome浏览器是如何存储密码的》这篇文章中获得了Chrome存储密码的诸多信息,而这篇文章是4年前写得。虽然从那篇文章以后Chrome做了些改变,但是我将按照同样的方式,利用Chromium源码的一些片段向你展示密码是如何转储的。
存储加密密码
当用户访问网站时,Chrome会首先判断此次登陆是否是一次成功的登录,判断代码片段:
如果成功登录,并且使用的是一套新的证书,这个证书是浏览器之前没有生成过的,Chrome就会生成一个提示条,询问用户是否需要记住密码:
在此为节省篇幅,我省略了创建提示条的代码。当点击“保存密码”时,就会调用Chrome密码管理器的“保存”函数来响应操作:
三、IE浏览器
获得密码难易程度:简单/一般/困难(依版本而定)
本质上来讲,直到IE10之前,IE浏览器的密码管理与Chrome使用的是相同的技术,但有一些有趣的改变。为了全面的展示,我们先简单讨论一下IE7——IE9的密码存储,然后再讨论在IE10中的变革。
IE7-9浏览器
在IE的早期版本中,根据密码类型的不同,会被存储于两个不同的地方:
注册表(基于表单的验证)——这类密码是提交给诸如Facebook、Gmail之类站点的。
证书文件——HTTP验证密码方式,类似于网上登陆证书。
根据本篇文章需要,接下来讨论基于表单验证的证书,这也是大多数攻击者可能选择的攻击目标。这些证书存储于如下注册表键位置:
HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\IntelliForms\Storage2
用regedit(注册表编辑器)可以看到值
正如Chrome中的示例,这些证书使用Windows API中的CryptProtectData函数加密后储存。不同之处是该函数添加了额外的熵(译者注:熵,熵就是混乱的程度,用来描述某个事件不断趋向混乱的过程)。这里的熵,就是这注册表键值,它是网站URL的SHA1的校验和,以供证书使用。
这非常有用,因为当用户访问网站时,IE能够迅速根据URL的哈希值判断是否已经有此证书,之后再用此哈希值完成证书解密。如果攻击者不知道此处使用了URL,解密证书就会变得很困难。
通常,攻击者能够通过历遍用户因特网访问历史,hash每个URL以及检查每个存储证书的方式,来降低此种保护方式的效果。
本文中没有贴出完整代码,你可以在这里处获得完整示例。现在,我们开始讨论IE10。
3/4
IE10浏览器
IE10改变了密码存储方式。所有自动填充的密码存储于证书管理中一个叫“web证书”的地方,如下图所示:
浏览器是如何存储密码的?
个人理解(在这个问题上我找不到更多的信息),这些证书文件存放于%APPDATA%\Local\Microsoft\Vault[random]目录下。关于这些文件是什么及其所用格式,可以在这里找到。
我所知道的是获得这些密码并不难。事实上,非常容易。为了支持windows的应用商店,微软提供了一个新的Windows runtime,用来支持更多地API访问。该winRT提供了对Windows.Security.Credentials namespace的访问接口,它提供了用来遍历用户证书的所有函数。
简单而言,存储bcrypt, scrypt等算法输出的内容,不要用salted hash的方式存储密码,不要用加密的方式存储密码,当然,更不要明文存储。顺带说一句,任何情况下尽可能的不要使用md5算法,而使用SHA系列的哈希算法。因为md5算法在很多地方被证明是很容易冲突的,另外md5的性能优势也完全可以忽略不计。为什么不能加密存储?加密存储的方式其实和明文存储没有区别。密码加密后一定能被解密获得原始密码,因此,该网站一旦数据库泄露,所有用户的密码本身就大白于天下。另外,管理员也存在获取原始密码,利用同样的帐号登陆其他互联网服务的可能。为什么不能用Hash存储? 单向Hash算法(MD5, SHA1, SHA256等)可以保证管理员几乎不能恢复原始密码。但它有两个特点:从同一个密码进行单向哈希,得到的总是唯一确定的摘要计算速度快。随着技术进步,尤其是显卡在高性能计算中的普及,一秒钟能够完成数十亿次单向哈希计算结合上面两个特点,考虑到多数人所使用的密码为常见的组合,攻击者可以将所有密码的常见组合进行单向哈希,得到一个摘要组合, 然后与数据库中的摘要进行比对即可获得对应的密码。这个摘要组合也被称为rainbow table。更糟糕的是,一个攻击者只要建立上述的rainbow table,可以匹配所有的密码数据库。为什么不能用Salt + Hash的方式存储?将明文密码混入“随机因素“,然后进行单向哈希后存储,也就是所谓的”Salted Hash”。 这个方式相比上面的方案,最大的好处是针对每一个数据库中的密码,都需要建立一个完整的rainbow table进行匹配。 因为两个同样使用“passwordhunter”作为密码的账户,在数据库中存储的摘要完全不同。
在线系统经常需要存储用户名和密码等认证信息,以便在用户登录的时候认证密码。但存储密码的时候其实有很多学问。在原始的互联网时期,使用明文存储密码。这是非常不可取的,经历了多次严重的泄露事件以后,即使是最蠢的程序员都知道得在存储前使用 MD5 哈希一下。
但现在我们已经知道使用 MD5 存储也不靠谱了。拿着 MD5 哈希后后的一串代码放到搜索引擎里面搜一下(彩虹表攻击),密码就出来了。对抗这种攻击的办法是给密码加盐,就是每次哈希前都带上一段随机字符串,并且和密码一起存储起来,这大大提高了破解的难度,即使你使用的是 123456 这么简单的密码,也不容易一眼就看出来了。
如今,MD5/SHA1 哈希算法已经被攻破,即使加上盐以后也不再保险。所以换成 SHA256/SHA512 就不说了。但即使这样,拿着常用密码本对着泄露后的数据库进行破解也是相对容易的事情。
除了存储密码,使用人类可读的密码来加密信息的时候也需要考虑类似的反破解要求。在密码学里面,像上面介绍的,从人类可读的密码生成一段不容易被逆向的密码哈希,称为 Key Derivation Function,简称 KDF. 从 password 生成加密用的 key,所以才叫 Key Derivation.
目前最常用的 KDF 方案可能是 PBKDF2. 它的前身 PBKDF1 只能生成 最多160 位的 key,所以被废弃了。
PBKDF2 算法可以表示为一个调用:
DK = PBKDF2(PRF, password, salt, c, dk_len)
其中参数 PRF 表示使用何种哈希算法,password 是人类可读密码,salt 是随机生成的盐,c 是迭代次数,dk_len 是最终生成的 key 的长度。当 DK 用于加密时,dk_len 一般是加密算法要求的 key 长度,也就是块大小。
它的原理就是加盐以及增加哈希迭代次数。当黑客拿到密码库,因为每次破解的尝试都需要大量的计算,最终会让黑客的破解变得非常困难,无利可图。在最佳实践里面,建议使用以下参数的 PBKDF2 算法:
PRF = SHA256
c = 100000
随便说一下,django 默认使用以上参数来存储密码。所以有人说 django 可能是出厂设置最安全的 web 框架了。
除了 PBKDF2,openbsd 开发了另外一种常用的 KDF 算法,名为 bcrypt. 它基于 blowfish 的特点,通过增加每次迭代的计算开销,达到提升破解难度的目标。
最近几年比特币挖矿的发展,让大家看到专有硬件、GPU 在对付大规模哈希时的威力。像 PBKDF2 这样简单使用 SHA256,看起来已经不太保险了。为了应付专有硬件的攻击, scrypt 算法被发明出来,它通过使用大量的内存来提升 ASIC 硬件的制作难度。于是这种算法也被顺理成章地被某些加密货币使用。比如 Litecoin 就使用 scrypt 作为它的 POW 算法。但据说已经有挖矿机被制造出来了。
scrypt 算法同样表示为了一个调用:
DK = Scrypt(salt, dk_len, n, r, p)
其中的 salt 是一段随机的盐,dk_len 是输出的哈希值的长度。n 是 CPU/Memory 开销值,越高的开销值,计算就越困难。r 表示块大小,p 表示并行度。在最佳实践里面推荐使用以下参数:
n = 2 ** 14 // 当密码用于登录时或者存储时
r = 8
p = 1
或者:
n = 2 ** 20 //加密非常敏感的文件
r = 8
p = 1
scrypt 的内存消耗很厉害。以上参数n = 2 ** 20, r = 8时消耗的内存是:
128 * ( 2 ** 20) * 8 = 1073741824
也就是说消耗了 1G 内存。
在 2015 年的”密码哈希大赛“里面,Argon2 算法胜出,成为最新的被推荐的 KDF 算法。这个算法可以使用多种参数控制内存使用、迭代次数、并行度等。不过可能是太新了,目前软件支持还不太好。在本文写作的时候(2018/07/16),openssl 仍然没有支持这个算法。
最后推荐一个 github 资源,里面有各种示例教大家使用 KDF 算法:
https://github.com/Anti-weakpasswords
简单而言,存储bcrypt, scrypt等算法输出的内容,不要用salted hash的方式存储密码,不要用加密的方式存储密码,当然,更不要明文存储。
顺带说一句,任何情况下尽可能的不要使用md5算法,而使用SHA系列的哈希算法。因为md5算法在很多地方被证明是很容易冲突的【2】,另外md5的性能优势也完全可以忽略不计。
为什么不能加密存储?
加密存储的方式其实和明文存储没有区别。密码加密后一定能被解密获得原始密码,因此,该网站一旦数据库泄露,所有用户的密码本身就大白于天下。另外,管理员也存在获取原始密码,利用同样的帐号登陆其他互联网服务的可能。
为什么不能用Hash存储?
单向Hash算法(MD5, SHA1, SHA256等)可以保证管理员几乎不能恢复原始密码。但它有两个特点:
1)从同一个密码进行单向哈希,得到的总是唯一确定的摘要
2)计算速度快。随着技术进步,尤其是显卡在高性能计算中的普及,一秒钟能够完成数十亿次单向哈希计算
结合上面两个特点,考虑到多数人所使用的密码为常见的组合,攻击者可以将所有密码的常见组合进行单向哈希,得到一个摘要组合, 然后与数据库中的摘要进行比对即可获得对应的密码。这个摘要组合也被称为rainbow table【3】。
更糟糕的是,一个攻击者只要建立上述的rainbow table,可以匹配所有的密码数据库。
为什么不能用Salt + Hash的方式存储?
将明文密码混入“随机因素“,然后进行单向哈希后存储,也就是所谓的”Salted Hash”。
这个方式相比上面的方案,最大的好处是针对每一个数据库中的密码,都需要建立一个完整的rainbow table进行匹配。 因为两个同样使用“passwordhunter”作为密码的账户,在数据库中存储的摘要完全不同。
10多年以前,因为计算和内存大小的限制,这个方案还是足够安全的,因为攻击者没有足够的资源建立这么多的rainbow table。 但是,在今日,因为显卡的恐怖的并行计算能力,这种攻击已经完全可行。
为什么bcrypt, scrypt等算法能保证密码存储的安全性?
这类算法有一个特点,算法中都有个因子,用于指明计算密码摘要所需要的资源和时间,也就是计算强度。计算强度越大,攻击者建立rainbow table越困难,以至于不可继续。
也就是说,故意延长一个密码匹配的计算时间,如果一个密码匹配需要1秒钟,那么匹配1000万个密码组合就需要115天,这个开销就非常大。
另外,这类算法也可以保证即使计算能力不断提高,只要调整算法中的强度因子,密码仍然不可能被轻易的攻破,同时不影响已有用户的登陆。关于这些算法的具体优劣
对于密码的存储,需要使用密码哈希来保证用户隐私与密码安全,对于这一点基本上是没什么争议的。但对于像用户登陆这样的场景来说,需要传递用户密码,这时是否需要使用哈希算法?如何保证密码安全?
首选HTTPS
HTTPS肯定是最安全的方式,这点是毋庸置疑的。有些人也很极端的说,“如果项目都不肯用HTTPS,这样的公司待下去也没啥意思”,这点我却不敢苟同了。实际上很多小项目或者是私活之类的事儿,你也很难希望人家会有额外的资金给你用于申请证书,然而作为一位有节操的码农,你肯定还是希望保护一下用户的密码。
至于在HTTPS下是否需要给密码哈希,这个就见仁见智了。使用HTTPS,密码传输安全实际上已经保证了,但对于一些监管不是很严格的公司,难保开发维护人员会在后台给你加上啥东西,没准直接就把用户密码打印到日志输出了,不少公司对代码管理其实都不是很严格,也难保程序员离职前给你搞一波什么事儿。另外,我还是觉得0.1并不等于0,做了保护总比明文要强一些,为此个人还是倾向于需要进行前台哈希。
对于APP端的接口开发,个人感觉没啥好说的,直接上HTTPS吧,哪怕你可以自己签发证书,只要在客户端上安装信任自己的根证书即可。
HTTP情况下的密码保护
方法一
服务器端存储密码为Hash(Random-Salt + Hash(Constant-SALT + Pasword))
客户端传输密码为Hash(Constant-SALT + Password)
Random-Salt为用户的随机盐,每个用户均不同,服务器端收到客户端的hash密码后,在将其与用户随机盐一起Hash运算,从而将结果与数据库存储值相比较。
优点:数据库存储的密码经过两层哈希,且其中包含了固定盐和用户随机盐,安全性较高
缺点:客户端传输的密码虽然经过了一层哈希,但是使用固定盐值,因此相同密码生成的结果均一致,容易收到重放攻击
方法二
服务器端存储密码为Hash(Constant-SALT + Pasword)
客户端传输密码为Hash(Random-Salt + Hash(Constant-SALT + Password))
Random-Salt为每次进入登陆页面时生成的随机盐,此处主要是为了防止重放攻击,作用类似与nonce
服务器收到客户端上传的hash之后的密码,根据用户ID取出服务器存储的密码,并通过与之前返回客户端的Random-Salt(可存储于Session中)进行Hash运算,从而与客户端上传的密码进行比较。
优点:客户端有效的防止了重放攻击
缺点:在前端代码中,固定盐值暴露,一旦后台数据库被攻破,用户密码信息将有危险。
综合比较
在HTTP的情况下,个人建议还是使用方法二较为合适,传输时的安全是HTTP的痛点。至于后台密码存储的安全,可使用BCrypt或SCrypt进行保证,将计算迭代次数调高,增加计算时间,从而防止彩虹表与字典攻击。
对于HTTPS的情况,如果同时希望将密码哈希后进行传输,则个人建议使用方法一。
至于Hash算法的选择,上述均涉及两层Hash算法,首先对于服务器端密码存储时,个人建议还是要使用BCrypt算法。而对于客户端密码传输时的Hash,可选用SHA256或者是将BCrypt算法的迭代次数调低一点,加快计算速度。总的来说,还是要控制完整的登陆哈希时间不要超过300ms。