开始为自己的项目构建身份验证功能之前,我们可能并未深入思考密码在幕后究竟经历了怎样的处理。这是因为身份验证库并未将全部秘密告诉我们:密码哈希与加盐机制。

如同大多数开发者所做的一样,我引入了一个加密库,调用了其中的哈希函数,将结果存入数据库,然后就不再关注它了。看着数据库里那一串诸如”$2a11yMMbLgN9uY6J3LhorfU9iu…”的随机字符串,我理所当然地认为:用户的密码已固若金汤,绝无被破解的可能。我确实知道那是一串经过哈希处理的密码,但其中的 `$2a` 究竟代表什么?那个 `11` 又是什么意思?既然哈希运算是不可逆的,那我的应用程序又是如何验证用户登录的呢?

如果曾使用过 bcrypt、Devise、Django 的认证系统,或者实际上任何其他的认证库,那么我们其实一直都被这些底层细节”屏蔽”在外了,虽然这正是优秀工程设计的体现。然而,深入理解其背后的运作原理,能帮助我们成长为一名更优秀的开发者;它还能为我们解开许多看似令人困惑或随意设定的谜团——一旦参透了原理,这些困惑便会瞬间烟消云散。

1] 哈希与加密

大多数开发者会交替使用哈希和加密这两个术语。但它们并不相同,而且这种区别比想象的要重要得多。

加密是一个双向过程。我们获取数据,用密钥对其进行加密,之后可以使用同一个密钥(或相关的密钥)对其进行解密。这在需要恢复原始值时非常有用,例如存储稍后需要扣款的信用卡号,或者发送收件人需要阅读的消息。

哈希则不同。它是一个单向过程。输入数据,就会得到一个固定长度的字符串,并且没有密钥可以还原它,因为原始值在哈希计算过程中已经丢失。

听起来可能是一个限制。但对于密码来说,这实际上正是我们想要的。

想想看:当用户登录时,系统不需要知道他们的密码。只需要验证用户输入的内容是否与他们注册时设置的密码一致。这可以完全使用哈希来实现这一点。对输入的内容进行哈希处理,与存储的哈希值进行比较,这样就完成了,完全不需要原始密码。

这就是为什么”忘记密码”流程总是要求设置新密码,而不是直接发送旧密码的原因。没错,虽然通过电子邮件发送旧密码可能存在风险,但真正的原因是确实无法找回旧密码。如果有系统能通过电子邮件发送原始密码,那就很可疑了。这意味着系统是以可逆的方式存储了密码,也就是说密码没有得到妥善保护。

2] 单独哈希计算还不够

既然哈希(Hashing)是单向且不可逆的,那这难道还不够吗?难道只要在存储密码前对每个密码进行哈希处理,就算大功告成了?

没那么简单。

首要的问题在于”彩虹表”(Rainbow Tables)。彩虹表是一个预先计算好的数据库,其中存储了常见密码对应的哈希值。一旦攻击者获取了密码数据库,他们根本无需费力去”反向破解”哈希值,只需直接进行查表匹配即可。举例来说,如果某位用户的密码是”password123″,其对应的 SHA-256 哈希值将始终是同一个字符串;而这个字符串,几乎可以肯定早已被收录在某个彩虹表中了。

第二个问题与此紧密相关。如果两名用户设置了相同的密码,那么他们各自对应的哈希值也将完全一致。这意味着,一旦攻击者成功破解了其中一个哈希值,也就相当于同时破解了所有使用该密码的用户账户。对于一个拥有众多用户的数据库而言,这无疑构成了一个重大的安全隐患。

在实际应用中,这种情况具体表现如下:

import hashlib

# Two users, same password
password = "password123"

hash_one = hashlib.sha256(password.encode()).hexdigest()
hash_two = hashlib.sha256(password.encode()).hexdigest()

print(hash_one)
print(hash_two)
print(hash_one == hash_two)  # True, every single time

哈希算法具有确定性:相同的输入总是产生相同的输出。这在很多情况下都很有用,但对于密码来说,它却造成了真正的安全漏洞。

普通的哈希算法可以解决一部分问题,但它本身并不足以完全解决问题。

2] 加盐

解决这两个问题的办法是使用一种叫做”盐(salt)”的东西。而且,它可不是平时吃的食盐。

盐是一个为每个密码生成的唯一随机字符串。在进行哈希运算之前,我们需要将盐与密码组合,然后对结果进行哈希处理。

import hashlib
import os

password = "password123"

# Generate a random salt
salt = os.urandom(16).hex()

# Combine salt and password, then hash
salted_password = salt + password
hashed = hashlib.sha256(salted_password.encode()).hexdigest()

print(f"Salt: {salt}")
print(f"Hash: {hashed}")

//输出结果:
Salt: 1cab8fe1fadb59660fe5923eba97bc7f
Hash: 90b095408f8838921befcda607833c25475f33e4042c3562e2e6e47ec3e2bca3

现在,即使两个用户使用相同的密码,也会生成完全不同的哈希值,因为他们的盐值不同。由于盐值是随机且唯一的,因此无法预先计算到彩虹表中。

令人惊讶的是:盐值不需要保密,它可以以明文形式与哈希值一起存储在数据库中。这乍一看可能不太合理。如果攻击者控制了数据库,他们也就拥有了盐值。

但这没关系,盐值的作用并非保密,而是确保每个哈希值都是唯一的,从而使预先计算的表失效。攻击者如果想要破解加盐哈希值,就必须使用特定的盐值,从头开始逐个暴力破解每个密码。他们无法重复利用跨用户的破解工作。所以即使盐值是可见的,这也显著增加了攻击成本。

3] 为什么 bcrypt 速度慢

加盐可以解决彩虹表问题,但仍然存在漏洞。如果攻击者控制了数据库并决定暴力破解密码,他们可以不断尝试。他们会用存储的盐值对候选密码进行哈希处理,然后将哈希值与存储的哈希值进行比较,如此反复。使用像 SHA-256 这样的快速哈希算法,现代 GPU 每秒可以进行数十亿次这样的比较。

这就是使用通用哈希函数处理密码的问题所在。像 SHA-256 和 MD5 这样的算法设计之初就以速度见长。这对于验证文件完整性或生成校验和等任务来说固然很好,但对于密码来说却是一种劣势。

而 bcrypt 算法的出现正是为了解决这个问题。bcrypt 是一种专门设计成慢速的密码哈希算法。它并非偶然地存在缺陷或效率低下,而是经过精心配置,故意降低速度。它有一个成本因子(有时也称为工作因子),用于控制哈希操作的计算成本。

import bcrypt

password = b"password123"

# The cost factor is set here (12 is a common production value)
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))

print(hashed)

//输出结果:
b'$2b$12$7hiNlgwvve0VE2CO6S8TkeJG4za7RHcqIbDC/achGoVgeZ475yHTi'

每增加 1 个成本因子(rounds),哈希运算所需时间大约翻倍。成本因子为 12 时,服务器上的单次哈希运算可能需要大约 300 毫秒。这对于登录用户来说几乎感觉不到。但对于试图暴力破解数百万个密码的攻击者而言,这会将原本可行的攻击变成几乎不可能的攻击。

可配置成本因子的另一个优势在于,我们可以随着硬件速度的提升而逐步增加成本因子。相同的成本因子在2015 年时速度可能已经够慢了,而如今就有可能就不够慢了。bcrypt 允许我们在不改变算法本身的情况下进行调整。

4] 数据库中实际存储的内容

到目前为止,我们一直将加盐和成本因素作为独立的概念进行讨论。令人欣喜的是:在 bcrypt 中,它们都被存储在一个字符串中。这个存储在数据库中的字符串包含了验证密码所需的一切信息,一旦知道如何解读它,它就完全不神秘了。

以下是一个典型的 bcrypt 哈希值:

$2a$12$yMMbLgN9uY6J3LhorfU9i/uLAUwKxyy8w42ubeL4MWy7Fh8B.CH/yO

让我们来详细分析一下:

  • $2a:算法版本。这会告诉身份验证库,用于生成哈希值的 bcrypt 算法的版本是什么
  • $12:成本因子。这是我们之前讨论过的数字。成本因子为 12 表示哈希运算运行了 2¹² 次
  • $yMMbLgN9uY6J3LhorfU9i:以一个反斜杠(/)结尾的 22 个字符是盐值,它以明文形式与哈希值一起存储。身份验证库会在验证登录时读取这些字符
  • uLAUwKxyy8w42ubeL4MWy7Fh8B.CH/yO:哈希值本身。其余字符即为哈希运算的实际输出

当用户登录时,我们使用的认证库无需任何额外信息。它会直接从已存储的字符串中读取算法版本、成本因子和盐值,利用这些相同的参数对本次登录尝试进行哈希运算,随后比对结果。如果两者匹配,即表明密码正确。

这正是 bcrypt 验证机制之所以有效的原因——尽管盐值从未被单独存储。因为从一开始,它就从未与主体数据分离过。

下次,如果再在数据库中看到 bcrypt 字符串时,我们就能确切地知道自己面对的是什么了。算法版本、成本因子(cost factor)、盐值(salt)以及哈希值——所有这些信息都被编码在同一个字符串中,而使用的身份验证库完全懂得如何解析它。

但更重要的启示在于:我们日常所依赖的各类库并非什么”魔法”。它们是一套套经过精心设计的系统,构建于一系列值得我们深入理解的核心概念之上。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注