如何正确散列

正确的方法:如何正确散列

本节准确地描述了密码应该如何被散列。第一部分涵盖了基础方法——所有技术都是绝对有必要被应用的。接下来的部分解释了在基础方法之上如何增强,使散列更难被破解。

基础方法:加盐散列

警告:不要只阅读本节。您绝对必须实现下一节中的内容:「使密码破解更难:慢速散列函数」。

我们已经看到了恶意黑客如何使用查找表和彩虹表非常快速地破解普通散列。我们已经了解到使用盐来随机化散列是解决这个问题的方法。但是我们如何生成盐,又如何将盐应用于密码呢?

盐应该使用 密码学安全伪随机数生成器(Cryptographically Secure Pseudo-Random Number Generator,CSPRNG) 生成。CSPRNG 与 C 语言 rand() 函数那样的普通的伪随机数生成器完全不同。顾名思义,CSPRNG 专为密码学安全而设计,这意味着它们可以提供高度随机性且完全不可预测。我们不希望我们的盐是可预测的,因此我们必须使用 CSPRNG。下表列出了一些流行编程平台提供的 CSPRNG。

平台CSPRNG
PHPmcrypt_create_ivopenssl_random_pseudo_bytes
Javajava.security.SecureRandom
Dot NET(C#,VB)System.Security.Cryptography.RNGCryptoServiceProvider
RubySecureRandom
Pythonsecrets
PerlMath::Random::Secure
C/C++(Windows API)CryptGenRandom
GNU/Linux 或 Unix 上的任何语言/dev/random 或 /dev/urandom 读取

每个用户每个密码的盐都应该是独一无二的。用户每次创建账户或更改密码时,都应该使用新的随机盐对密码进行散列。永远不要重复使用盐。盐需要足够长,这样可用的盐会足够多。根据经验,盐至少应该与散列函数的输出长度一样长。盐应该与散列一起存储在用户账户表中。

存储密码时

  1. 使用 CSPRNG 生成长随机盐。
  2. 在密码前附加盐并使用标准密码散列函数(如 Argon2、bcrypt、scrypt 或 PBKDF2)对其进行散列。
  3. 将盐和散列保存在用户数据库记录中。

验证密码时

  1. 从数据库中检索用户的盐和散列。
  2. 将盐附加到给定的密码前并使用相同的散列函数对其进行散列。
  3. 将给定密码的散列值与数据库中的散列值进行比较。如果匹配,则密码是正确的。否则,密码不正确。

在 Web 应用程序中,始终在服务器上散列

如果您正在编写 Web 应用程序,您可能想知道在哪里运行散列。密码应该在用户的浏览器中使用 JavaScript 散列,还是应该「以明文形式」发送到服务器并在服务器中散列?

即使您在 JavaScript 中散列了用户的密码,您仍然必须在服务器上对散列值再次进行散列。考虑一个网站,它在用户的浏览器中散列了用户的密码,却没有散列服务器上的散列值。为了对用户进行身份验证,该网站将接受来自浏览器的散列值并检查该散列值是否与数据库中的散列值完全匹配。这似乎比仅在服务器上散列更安全,因为用户的密码永远不会被发送到服务器,但事实并非如此。

问题在于,客户端的散列在逻辑上成为了用户的密码。用户进行身份验证所要做的就是告诉服务器他们密码的散列值。如果坏人获得了用户密码的散列值,他们就可以使用散列值用于服务器的身份验证,根本不需要知道用户的密码!因此,如果坏人以某种方式从这个假设的网站窃取了散列数据库,将可以立即访问每个人的账户,而无需猜测任何密码。

这并不是说您不应该在浏览器中散列,但如果您这样做了,也绝对必须在服务器上再次散列。在浏览器中进行散列当然是一个好主意,但在您的实现中,需要注意以下几点:

  • 客户端密码散列并不能替代 HTTPS(SSL/TLS)。如果浏览器和服务器之间的连接不安全,中间人可以在浏览器下载 JavaScript 代码时修改它,删除散列功能并获取用户密码。

  • 有些网络浏览器不支持 JavaScript,有些用户会在他们的浏览器中禁用 JavaScript。因此,为了获得最大的兼容性,您的应用程序应该检测浏览器是否支持 JavaScript,如果不支持,则在服务器上模拟客户端散列。

  • 您还需要对客户端散列进行加盐。容易想到的解决方案是让客户端脚本向服务器请求用户的盐。不要这样做,因为这会让坏人在不知道密码的情况下检查用户名是否有效。由于您同样需要在服务器上进行散列和加盐(可以使用更好的盐),因此可以将用户名(或电子邮件)与特定于站点的字符串(例如域名)拼接在一起作为客户端的盐。

使密码破解更难:慢速散列函数

盐可以确保攻击者无法使用查找表和彩虹表等特定攻击来快速破解大量散列集合,但并不能阻止他们分别对每个散列实施字典攻击或暴力破解。高端显卡(GPU)和定制硬件每秒可以计算数十亿个散列值,因此这种攻击仍然非常有效。为了降低这种攻击的效率,我们可以使用一种称为 密钥延伸(Key Stretching) 的技术。

密钥延伸的想法是让散列函数变得非常慢,这样即使使用高速 GPU 或定制硬件,字典攻击和暴力破解也因太慢而变得不值得。目标是,使散列函数足够慢以阻止攻击,但仍然快到不会让用户感知到明显的延迟。

密钥延伸是使用一种特殊类型的 CPU 密集型散列函数实现的。不要试图发明您自己的版本——对密码的散列简单地进行迭代散列是不够的,因为这可以在硬件中并行化,和正常散列执行得一样快。应该使用 PBKDF2bcrypt 这样的标准算法。您可以在此处找到 PBKDF2 的 PHP 实现。

这些算法将安全系数或迭代次数作为参数。这个值决定了散列函数的速度有多慢。对于桌面软件或智能手机应用程序来说,选择此参数的最佳方法是在设备上运行一个简短的基准测试,找到使散列花费大约半秒时间的值。这样,您的程序可以在不影响用户体验的同时尽可能安全。

如果您在 Web 应用程序中使用密钥延伸散列,请注意您将需要额外的计算资源来处理大量身份验证请求,而且密钥延伸可能会让您的网站更容易遭到拒绝服务(Denial of Service,DoS)攻击。我仍然建议使用密钥延伸,但应该设置较小的迭代次数。您应该根据您的计算资源和预期的最大身份验证请求率来计算迭代次数。可以让用户在每次登录时输入验证码来消除拒绝服务威胁。总是应该将您的系统设计为可以方便地增加或减少迭代次数,以便将来进行调节。

如果您担心计算负担,但仍想在 Web 应用程序中使用密钥延伸,请考虑使用 JavaScript 在用户浏览器中运行密钥延伸算法。斯坦福 JavaScript 加密库中包含了 PBKDF2。迭代次数应该设置得足够低,以便系统可以用于移动设备等速度较慢的客户端,如果用户的浏览器不支持 JavaScript,系统应该回退到服务器端进行计算。在客户端进行了密钥延伸并不意味着不需要在服务器端进行散列。您必须像散列正常密码一样散列客户端生成的散列值。

不可能破解的散列:密钥散列和密码散列硬件

只要攻击者可以使用散列来检查密码猜测是否正确,他们就可以对散列进行字典攻击或暴力破解。让散列更安全的下一步是向散列添加一个私钥,只有知道密钥的人才可以使用散列来验证密码。这可以通过两种方式实现。可以使用 AES 等加密算法对散列进行加密,也可以使用 HMAC 等密钥散列算法将私钥包含到散列中。

这并不像听起来那么容易。即使在系统被攻破的情况下,密钥也必须对攻击者保密。如果攻击者获得了系统的完全访问权限,无论密钥存储在哪里,攻击者都能窃取它。密钥必须存储在外部系统中,例如专门用于密码验证的物理隔离服务器,或者连接到服务器的特殊硬件设备,例如 YubiHSM

我强烈推荐任何大规模(超过 100,000 个用户)的服务采用这种做法。我认为对于任何托管超过 1,000,000 个用户账户的服务来说,这种做法都是不可或缺的。

如果您负担不起多台专用服务器或特殊硬件设备,您仍然可以在标准 Web 服务器上享受到密钥散列的好处。大多数数据库都是被 SQL 注入攻击攻破的,在许多情况下,攻击者无法访问本地文件系统(如果您的 SQL 服务器具有本地文件系统访问的功能,请禁用它)。如果您生成了一个随机密钥,将其存储在一个无法从 Web 访问的文件,并将其包含在加盐散列中,那么当您的数据库被简单的 SQL 注入攻击攻破时,散列就不会遭到攻击。不要将密钥硬编码到源代码中,应该在安装应用程序时随机生成它。这并不如使用隔离的系统进行密码散列那样安全,因为如果 Web 应用程序中存在 SQL 注入漏洞,那么也可能有其他类型的漏洞(例如本地文件包含)可以让攻击者读取私钥文件。但是,这总比什么都不做更好。

请注意,使用密钥散列并不意味着不需要加盐。聪明的攻击者最终总会找到读取密钥的方法,因此保证散列值此时仍然受到盐和密钥延伸的保护是很重要的。

其他安全措施

密码散列仅可以在安全漏洞被利用时保护密码。它不会使应用程序整体更加安全。必须做更多的工作来防止密码散列(和其他用户数据)在第一时间被盗。

为了编写安全的应用程序,即使是有经验的开发人员也必须接受安全方面的培训。了解 Web 应用程序漏洞的一个不错的资源是开放 Web 应用程序安全项目(The Open Web Application Security Project,OWASP)。一份很好的介绍是 OWASP 十大漏洞列表。除非您已经理解了列表中的所有漏洞,否则不要尝试编写处理敏感数据的 Web 应用程序。雇主有责任确保所有开发人员都接受过安全应用程序开发方面的充分培训。

让第三方对您的应用程序进行「渗透测试」是个好主意。即使是最优秀的程序员也会犯错,因此让安全专家检查代码是否存在潜在漏洞总是有意义的。应该找一个值得信赖的组织(或雇佣员工)定期检查您的代码。安全审查过程应该在应用程序生命周期的早期开始,并贯穿整个开发过程。

如果确实发生了攻击,监控您的网站来检测到攻击行为也很重要。我建议至少雇用一名全职人员来检测和响应安全漏洞。如果攻击未被检测到,那么攻击者就可以利用您的网站让访问者感染恶意软件,因此检测漏洞并迅速做出响应非常重要。