没那么快。事实上,风险与碰撞无关。它更多的是关于第二个原像。
冲突是当有人可以找到哈希函数的两个不同输入时,它们会哈希到相同的值。攻击者可以控制两个输入。在您的情况下,攻击者将计算两个特制的电子邮件地址,然后注册这两个地址,并且稍后,将能够从一个帐户获取发送到另一个帐户的数据。它不会向攻击者购买任何东西:他已经拥有两个帐户。
第二个原像是当攻击者看到一个输入时,并面临寻找另一个哈希到相同值的挑战。这与碰撞攻击的设置不同:那时,攻击者只能控制一个输入,而不是两者。这使它变得更加困难。这映射到您的情况:攻击者想要注册一个与目标帐户的电子邮件地址哈希值相同的电子邮件地址,以便攻击者可以声称该目标帐户的遗忘,并将用户名和密码重置链接邮寄回他的地址。
如果你使用一个像样的散列函数,那么就不必担心冲突,因为它们是完全不可能的;而第二个原像甚至更不可行。对于具有n位输出的强哈希函数,发现碰撞的成本(结合运气和原始功率)为2 n/2,如果n ≥ 200左右,这在技术上已经不可行;对于第二个原像,成本上升到2 n,高出数十亿倍。有关碰撞可能性的更多信息,请参阅此答案。
正如您所说,您将希望您的哈希函数成为密码哈希函数(即类似 bcrypt,带有盐和多次迭代)以阻止完全不同类型的攻击,即攻击者窃取电子邮件哈希并破解它们能够向他们发送垃圾邮件。必须注意的是,一个给定的函数理论上可能被认为是“良好的密码散列函数”(即在存储密码散列时是安全的),但实际上并不能抵抗冲突甚至第二原像。密码散列的要求不包括安全散列的所有要求。
一个典型的例子是PBKDF2:就密码散列函数而言,它被认为是相当不错的。但是,它不耐冲突(这是因为它使用 HMAC,而 HMAC 使用密钥K,在 PBKDF2 中,它是密码;并且当K的长度超过底层哈希函数,然后K被替换为h(K);所以一个大的K产生与h(K)完全相同的输出。幸运的是,你不介意碰撞;你只需要抵抗第二个原像,PBKDF2就可以了。
这一点说明了在处理密码学时需要使用精确的术语。如果你不了解细节,那么这更能说明问题:密码学是微妙的。
总结:使用 bcrypt 或 PBKDF2,你担心的风险是不存在的。在实践中不会发生;攻击者将无法强迫它。你不应该担心它,因为还有其他“风险”,它们的可能性要大几十亿倍,而且你不用担心(或者去买一把霰弹枪!)。
作为旁注,您需要在散列之前规范化电子邮件地址(例如强制小写),因为至少部分电子邮件地址不区分大小写,并且您不能期望用户总是对自己的电子邮件地址使用相同的大小写。
作为另一个方面说明,由于 bcrypt/PBKDF2 是昂贵的函数,您将只想对提交的电子邮件地址进行一次哈希处理——这意味着您必须知道要使用什么盐;如果您有 1000 个存储的电子邮件地址,您将无法承受将其哈希一千次的代价。因此,必须假设忘记密码的用户实际上记得他的用户名,以便您的服务器使用正确的盐计算正确的哈希值。这是我上面使用的假设。
或者,不要使用加盐散列,以便您可以对电子邮件地址进行一般散列,并将生成的散列值用作数据库中的索引。但是,这会削弱您的哈希对第二种攻击类型的抵抗力:当攻击者设法窃取您的数据库副本(SQL 注入、丢失备份磁带......)时,他会发现“反转”哈希更容易并恢复电子邮件地址。你必须选择你的毒药...
在那个 email-hash-as-index 的情况下,你再次需要担心冲突,因为发现冲突的攻击者将能够强制你的服务器尝试记录一个 email-hash-indexed 条目并找到一个现有的具有相同哈希的条目——取决于你如何实现它,这可能是也可能不是问题。事实上,“共享盐”模型意味着密码散列函数并不适合。我们回到了“加密设计”阶段,这需要更多的思考。如果你真的想走那条路,那么你可以期待困难。
(作为一个起点,我会设想一个自定义嵌套哈希为h(h(h(...h(email)...))),其中h = SHA-256,以及数千或数百万次迭代,但是在将其部署到生产环境之前需要更多的思考时间。)