常见问题

我应该使用什么散列算法?

可以使用:

不要使用:

  • MD5、SHA1、SHA256、SHA512、RipeMD、WHIRLPOOL、SHA3 等快速加密散列函数。
  • crypt 的不安全版本($1$,$2$,$2x$,$3$)。
  • 您自己设计的任何算法。应该只使用公共领域,且经过有经验的密码学家们充分测试的技术。

尽管还没有对 MD5 或 SHA1 的密码学攻击来让它们的散列更容易被破解,它们也已经过时并且被广泛认为(好像还不够广泛)强度不足以存储密码。所以我不建议使用它们。此规则的一个例外是 PBKDF2,它的实现经常把 SHA1 作为底层散列函数。

当用户忘记密码时,我应该如何让用户重置密码?

我个人认为,当今广泛使用的所有密码重置机制都是不安全的。如果您有很高的安全要求,例如提供加密服务,请不要让用户重置他们的密码。

大多数网站使用电子邮件循环来对忘记密码的用户进行身份验证。为此,需要生成一个与账户紧密相关的随机一次性令牌。将令牌包含在发送到用户电子邮件地址的密码重置链接中。当用户点击包含有效令牌的密码重置链接时,提示他们输入新密码。需要确保令牌与用户账户紧密相关,使攻击者无法用发送到自己电子邮件地址的令牌来重置其他用户的密码。

令牌必须设置为在 15 分钟后或使用后到期,以先到者为准。当用户登录(想起了密码)或请求另一个重置令牌时,让任何现有的密码令牌过期也是一个不错的主意。如果令牌永不过期,就可以永远被用于闯入用户的账户中。电子邮件(SMTP)是一种纯文本协议,互联网上可能存在记录电子邮件流量的恶意路由器。此外,用户的电子邮件账户(包括重置链接)可能会在用户密码更改很长时间之后被盗用。使令牌尽快到期可以减少用户遭受这些攻击的风险。

攻击者将会有能力修改令牌,因此不要在令牌中存储用户账户信息或超时信息。令牌应该是一个不可预测的随机二进制大型对象(BLOB),仅用于标识数据库表中的记录。

永远不要通过电子邮件向用户发送新密码。不要忘记在用户重置密码时选择一个新的随机盐。不要重复使用用于散列旧密码的盐。

如果我的用户账户数据库被泄露/入侵,我应该做什么?

您的首要任务是确定系统是如何被入侵的,并修补攻击者用来入侵的漏洞。如果您没有应对入侵行为的经验,我强烈建议您聘请第三方安全公司。

选择掩盖入侵行为并且希望没有人注意到入侵的发生可能十分诱人。但是,试图掩盖入侵行为会让您看起来更不负责任,因为您没有通知用户他们的密码和其他个人信息可能已经被泄露,从而使他们面临更大的风险。您必须尽快通知您的用户——即使您还没有完全理解发生了什么。请在您网站的首页放上一条通知,链接到包含更详细信息的页面,并尽可能通过电子邮件向每个用户发送通知。

向您的用户准确地解释他们的密码是如何被保护的——希望您使用了加盐散列——即使密码受到了加盐散列的保护,恶意黑客仍然可以对散列实施字典攻击和暴力破解。恶意黑客将使用他们找到的任何密码尝试登录不同网站上的用户账户,期望用户在两个网站上使用了相同的密码。将这种风险告知您的用户,并建议他们在使用了类似密码的任何网站或服务上更改密码。强制他们在下次登录您的服务时更改密码。大多数用户会尝试将他们的密码「更改」为与原来相同的密码来快速绕过强制更改。请使用当前密码的散列值来确保他们不能这样做。

即使使用了加盐的慢速散列,攻击者也很可能会很快破解出一些弱密码。为了减小攻击者使用这些密码的机会窗口,除了当前密码之外,您还应该要求用户使用电子邮件循环来进行身份验证,直到用户更改密码为止。请阅读上一个问题「当用户忘记密码时,我应该如何让用户重置密码?」中关于实现电子邮件循环身份验证的提示。

还应该将网站上存储了哪些类型的个人信息告知您的用户。如果您的数据库包含信用卡号,您应该指导您的用户仔细查看他们最近和未来的账单并注销他们的信用卡。

我的密码策略应该如何设置?我应该强制用户使用强密码吗?

如果您的服务没有严格的安全要求,就不要限制您的用户。我建议在用户输入密码时向他们展示有关密码强度的信息,让他们自己决定密码的安全性。如果您有特殊的安全需求,请强制要求密码的长度至少为 12 个字符,且至少有两个字母、两个数字和两个符号。

不要强迫您的用户以六个月一次更高的频率更改密码,因为这样做会造成「用户疲劳」,让用户不太可能选择好的密码。相反,应该培训用户在他们觉得密码已经被泄露时更改密码,并且永远不要将他们的密码告诉任何人。如果是商业环境,应该鼓励员工利用带薪时间来记住和练习他们的密码。

如果攻击者可以访问我的数据库,他们难道不能用自己的散列值替换掉我的密码散列值再登录吗?

的确可以,但是如果有人可以访问您的数据库,他们可能已经能够访问您服务器上的所有内容,因此他们无需登录您的账户就能获得他们想要的任何内容。在网站中使用的密码散列技术目的不在于保护网站不被攻破,而是在网站已经被攻破时保护密码。

您可以使用具有不同权限的两个用户连接到数据库来防止散列值在 SQL 注入攻击期间被替换。一个用户用于「创建账户」部分的语句,另一个用户用于「登录」部分的语句。「创建账户」部分的语句应该能读取和写入用户表,但「登录」部分的语句应该只能读取。

为什么我必须使用像 HMAC 这样的特殊算法?为什么我不能直接将密码附加到私钥后面?

MD5、SHA1 和 SHA2 等散列函数使用了 Merkle–Damgård 结构,会让它们容易受到所谓的长度扩展攻击。这意味着给定一个散列 H(X),攻击者可以在不知道 X 的情况下找到任何其他字符串 Y 的 H(pad(X) + Y) 的值。pad(X) 是散列使用的填充函数。

这意味着给定散列 H(密钥 + 消息),攻击者可以在不知道密钥的情况下计算出 H(pad(密钥 + 消息) + 扩展)。如果散列被用作消息验证码,使用密钥来防止攻击者修改消息并将消息替换为不同的有效散列,则系统会失败,因为现在攻击者已经拥有了有效的消息 + 扩展的散列。

目前尚不清楚攻击者如何使用这种攻击来更快地破解密码散列。但是,由于这种攻击的存在,使用普通散列函数进行密钥散列被认为是不好的做法。一位聪明的密码学家有可能会在某天想出一种聪明的方法来利用这些攻击加快破解速度,因此请使用 HMAC。

盐应该在密码之前还是之后?

这不重要,但为了互操作性,请选择一种并坚持下去。在密码之前加盐似乎更常见。

为什么此页面上的散列代码会在「长度恒定」的时间内比较散列值?

在「长度恒定」的时间内比较散列值可以确保攻击者无法使用定时攻击从在线系统中提取到密码的散列值,然后离线破解它。

检查两个字节序列(字符串)是否相同的标准方法是先比较第一个字节,然后比较第二个,接着比较第三个,依此类推。一旦您发现两个字符串的某个字节不同,就会得知字符串是不同的,可以立即返回否定响应。如果您比较完了两个字符串却没有发现任何不同的字节,就可以确信字符串是相同的并返回肯定结果。这意味着比较两个字符串可能需要不同的时间,具体取决于字符串匹配的程度。

例如,用标准比较方法比较字符串「xyzabc」和「abcxyz」会立即发现第一个字符就是不同的,并不会检查字符串的其余部分。另一方面,当比较字符串「aaaaaaaaaaB」和「aaaaaaaaaaZ」时,比较算法在确定字符串不相等之前会扫描所有的「a」。

假设攻击者想要闯入一个将身份验证尝试速率限制为每秒一次的在线系统。还假设攻击者知道密码散列的所有参数(盐、散列类型等),除了散列本身和(显然不知道的)密码。如果攻击者能够精确测量在线系统将真实密码的散列值与攻击者提供密码的散列值进行比较所需的时间,就可以使用定时攻击来提取部分散列并使用离线攻击破解它,从而绕过系统的速率限制。

首先,攻击者找到 256 个字符串,其散列值以每个可能的字节开头。先将每个字符串发送到在线系统,并记录系统响应所需的时间。花费时间最长的字符串就是散列的第一个字节与真实散列的第一个字节相匹配的字符串。攻击者得知了第一个字节后,可以用类似的方法继续攻击第二个字节、第三个字节,依此类推。一旦攻击者对散列值已经了解得足够多,就可以使用自己的硬件来破解它,不受系统的速率限制。

在网络上实施定时攻击看上去似乎是不可能的。然而,这种攻击已经被完成过,并且已被证明是实用的。这就是为什么此页面上的代码以一种无论字符串匹配程度有多高都花费相同时间进行比较的原因。

SlowEquals 代码是如何工作的?

上一个问题解释了为什么 SlowEquals 是必要的,而这个问题解释了代码实际的工作方式。

private static boolean slowEquals(byte[] a, byte[] b)
{
    int diff = a.length ^ b.length;
    for(int i = 0; i < a.length && i < b.length; i++)
        diff |= a[i] ^ b[i];
    return diff == 0;
}

代码使用异或(XOR)的「^」运算符来比较整数是否相等,而不是「==」运算符。原因将在下面解释。当且仅当两个整数完全相同时,异或这两个整数的结果才为零。这是因为 0 XOR 0 = 0,1 XOR 1 = 0,0 XOR 1 = 1,1 XOR 0 = 1。如果我们将异或应用于两个整数中的所有位,那么仅当所有位都匹配时,结果才会为零。

因此,在第一行中,如果 a.length 等于 b.length,则 diff 变量将会是零,如果不相等,diff 变量将会是非零值。接下来,我们使用 XOR 比较字节,并将结果 OR 到 diff 中。如果字节不同,就会将 diff 设置为非零值。由于 OR 永远不会取消设置位,所以当循环结束时 diff 为零的唯一情况是,它在循环开始之前就为零(a.length == b.length),且两个数组中的所有字节都匹配(没有任何一个 XOR 得到了非零值)。

我们需要使用 XOR 而不是「==」运算符来比较整数的原因是「==」通常会被翻译/编译/解释为一个分支。例如,C 代码「diff &= a == b」可能会被编译成以下 x86 汇编:

MOV EAX, [A]
CMP [B], EAX
JZ equal
JMP done
equal:
AND [VALID], 1
done:
AND [VALID], 0

根据整数是否相等和 CPU 内部的分支预测状态,分支会使代码在不同的时间内被执行。

C 代码「diff |= a ^ b」应该会被编译成以下内容,其执行时间不依赖于整数是否相等:

MOV EAX, [A]
XOR EAX, [B]
OR [DIFF], EAX

为什么要用散列这么麻烦的东西?

您的用户正在您的网站中输入他们的密码。他们信任您并将他们的安全交给了您。如果您的数据库遭到入侵,而且用户的密码未受任何保护,则恶意黑客就可以使用这些密码来破坏您的用户在其他网站和服务上的账户(大多数人在所有地方都使用相同的密码)。面临风险的不仅是您的安全,还有您用户的安全。您应该对用户的安全负责。