在前一篇文章《设计安全的账号系统的正确姿势》中,主要提出了一些设计的方法和思路,并没有给出一个更加具体的,可以实施的安全加密方案。经过我仔细的思考并了解了目前一些方案后,我设计了一个自认为还比较安全的安全加密方案。本文主要就是讲述这个方案,非常欢迎和期待有读者一起来讨论。
首先,我们明确一下安全加密方案的终极目标:
即使在数据被拖库,代码被泄露,请求被劫持的情况下,也能保障用户的密码不被泄露。
说具体一些,我们理想中的绝对安全的系统大概是这样的:
- 首先保障数据很难被拖库。
- 即使数据被拖库,攻击者也无法从中**出用户的密码。
- 即使数据被拖库,攻击者也无法伪造登录请求通过验证。
- 即使数据被拖库,攻击者劫持了用户的请求数据,也无法**出用户的密码。
如何保障数据不被拖库,这里就不展开讲了。首先我们来说说密码加密。现在应该很少系统会直接保存用户的密码了吧,至少也是会计算密码的 md5 后保存。md5 这种不可逆的加密方法理论上已经很安全了,但是随着彩虹表的出现,使得大量长度不够的密码可以直接从彩虹表里反推出来。
所以,只对密码进行 md5 加密是肯定不够的。聪明的程序员想出了个办法,即使用户的密码很短,只要我在他的短密码后面加上一段很长的字符,再计算 md5 ,那反推出原始密码就变得非常困难了。加上的这段长字符,我们称为盐(Salt),通过这种方式加密的结果,我们称为 加盐
Hash 。比如:
上一篇我们讲过,常用的哈希函数中,SHA-256、SHA-512 会比 md5 更安全,更难**,出于更高安全性的考虑,我的这个方案中,会使用 SHA-512 代替 md5 。
通过上面的加盐哈希运算,即使攻击者拿到了最终结果,也很难反推出原始的密码。不能反推,但可以正着推,假设攻击者将 salt 值也拿到了,那么他可以枚举遍历所有 6 位数的简单密码,加盐哈希,计算出一个结果对照表,从而**出简单的密码。这就是通常所说的暴力**。
为了应对暴力**,我使用了加盐的慢哈希。慢哈希是指执行这个哈希函数非常慢,这样暴力**需要枚举遍历所有可能结果时,就需要花上非常非常长的时间。比如:bcrypt 就是这样一个慢哈希函数:
通过调整 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 **进行加密。
假设被拖库了
以上就是整个加密传输、存储的全过程。我们来假设几种攻击场景:
-
假设数据被拖库了,密码会泄露吗?
数据库中的 Salt1 ,Salt2 , SaltHash2 暴露了,想从 SaltHash2 直接反解出原始密码几乎是不可能的事情。
-
假设数据被拖库了,攻击者能不能伪造登录请求通过验证?
攻击者在生成 Ticket 时,需要 SaltHash1 ,但由于并不知道密码,所以无法计算出 SaltHash1 ,又无法从 SaltHash2 反推 SaltHash1 ,所以无法伪造登录请求通过验证。
-
假设数据被拖库了,攻击者使用中间人攻击,劫持了用户的请求,密码会被泄露吗?
中间人拥有真实服务器所有的数据,仿冒了真实的 Server ,因此,他可以解密出 Ticket 中的 SaltHash1 ,但是 SaltHash1 是无法解密出原始密码的。所以,密码也不会被泄露。
但是,中间人攻击可以获取到最后的 TempKey ,从而能监听后续的所有通信过程。这是很难解决的问题,因为在服务端所有东西都暴露的情况下,中间人假设可以劫持用户数据,仿冒真实 Server , 是很难和真实的 Server 区分开的。解决的方法也许只有防止被中间人攻击,保证 Server 的公钥在客户端不被篡改。
假设攻击已经进展到了这样的程度,还有办法补救吗?有。由于攻击者只能监听用户的登录过程,并不知道真实的密码。所以,只需要在服务端对 Salt2 进行升级,即可生成新的 SaltHash2 ,从而让攻击者所有攻击失效。
具体是这样的:用户正常的登录,服务端验证通过后,生成新的 Salt2 ,然后根据传过来的 SaltHash1 重新计算了 SaltHash2 存入数据库。下次用户再次登录时,获取到的是新的 Salt2 ,密码没有变,同样能登录,攻击者之前拖库的那份数据也失效了。
Q & A
-
使用 bcrypt 慢哈希函数,服务端应对大量的用户登录请求,性能承受的了吗?
该方案中,细心一点会注意到, bcrypt 只是在客户端进行运算的,服务端是直接拿到客户端运算好的结果( SaltHash1 )后 SHA-512 计算结果进行验证的。所以,把性能压力分摊到了各个客户端。
-
为什么要使用两个 Salt 值?
使用两个 Salt 值,是为了防止拖库后,劫持了用户请求后将密码**出来。只有拥有密码的用户,才能用第一个 Salt 值计算出 SaltHash1 ,并且不能反推回原始密码。第二个 Salt 值可以加大被拖库后直接解密出 SaltHash1 的难度。
-
为什么要动态请求 Salt1 和 Salt2 ?
Salt 值直接写在客户端肯定不好,而且写死了要修改还得升级客户端。动态请求 Salt 值,还可以实现不升级客户端的情况下,对密码进行动态升级:服务端可定期更换 Salt2 ,重新计算 SaltHash2 ,让攻击者即使拖了一次数据也很快处于失效状态。
-
数据库都已经全被拖走了,密码不泄露还有什么意义呢?
其实是有意义的,正如刚刚提到的升级 Salt2 的补救方案,用户可以在完全不知情的情况下,不需要修改密码就升级了账号体系。同时,保护好用户的密码,不被攻击者拿去撞别家网站的库,也是一份责任。
欢迎大家针对本文的方案进行讨论,如有不实或者考虑不周的地方,请尽情指出。或者有更好的建议或意见,欢迎交流!
更新:反馈汇总
-
“应该从源头上禁止用户使用简单密码”
回复:非常同意!
-
“获取 salt 并不需要啥验证,那么还有必要分开存储么,脱裤者直接根据uid调一遍接口不就拿到了?相当于就是公开的吧?”
回复:确实是这样。salt 相当于公开的了,没有必要分开存储了。如果你有更好的方法,请告诉我。
-
“使用 HTTPS(SSL/TLS) 来保障传输的安全是不是就可以了?”
回复:理论上是足够了,而且推荐使用。 如果你的项目安全级别非常高,本着不信任绝对安全的角度可考虑再一层加固。
-
“salt 使用密码学安全的随机数生成就够了,不需要使用 uid 。”
回复:同意,确实不是很必要。
-
“服务器性能够强劲,bcrypt 放在服务端执行也没什么问题。”
回复:通过调整 bcrypt 参数让服务端执行在可接受的时间范围内确实可以。但是把这种耗时的操作放到客户端去做不是更好吗?
-
“不知攻焉知防,还是使用现有的算法和协议比较好,不要自己发明。即使自己发明,也需要经过实践的检验不断迭代才行。”
回复:首先,文中用到的都是现有的成熟算法,如 bcrypt,SHA-512, AES ,包括 ECDH,并没有重新发明什么。文章重点是对密码的两次加盐哈希以及密码的验证方式。当然,方案需要在实践中不断迭代优化,我也是不能同意再多。
有一位朋友说的非常好,很多互联网公司对安全不重视,近年来密码安全事故频繁发生,导致密码泄露后被拿去撞库,用户利益受损。应该去推动一下密码安全的业界标准,避免企业犯错用户买单。同时,互联网没有绝对的安全,强烈建议用户不要用同一个密码,密码定期改!
转自:https://blog.coderzh.com/2016/01/10/a-password-security-design-example/