在开始将自己的项目中加入身份验证功能之前,我并没有深入思考过密码在后台究竟经历了什么处理过程。

和大多数开发者一样,我只是安装了一个名为“哈希函数”的库,将处理后的结果存储起来,然后就继续开发了。我在数据库中看到像 \(2a11yMMbLgN9uY6J3LhorfU9iu.... 这样的随机字符串,就认为用户的密码是无法被破解的。我知道这些是经过哈希处理的密码,但其中的 \)2a11 是什么意思呢?如果我无法还原这些密码,我的应用程序又是如何进行登录验证的呢?

如果你曾经使用过 bcrypt、Devise、Django 的身份验证系统,或者任何其他的认证库,那么这些细节其实已经为你所忽略了。这种设计确实很合理,但了解其背后的工作原理会让你成为一名更优秀的开发者,同时也能帮助你理解那些看似复杂或随意的设计决策,让它们变得清晰易懂。

读完这篇文章后,你将能够看懂那段字符串中每个部分的含义。

先决条件

本文适用于那些之前使用过身份验证库,但从未仔细了解其工作原理的开发者。你不需要具备密码学方面的专业知识,只要曾经对密码进行过哈希处理,那么这篇文章就很适合你。

目录

  1. 哈希与加密的区别

  2. 为什么单纯的哈希处理不够用

  3. 盐值添加的重要性

  4. 为什么 bcrypt 效率较低——而这恰恰是它的设计目的

  5. 你的数据库中实际上存储了什么

  6. 总结

哈希与加密的区别

大多数开发者会将“哈希”和“加密”这两个术语混为一谈。但实际上,它们是不同的概念,而且这种区别的重要性远超你的想象。

加密是一个双向过程:你使用密钥对数据进行处理,之后就可以用同样的密钥或相关的密钥来解密这些数据。当你需要恢复原始数据时,这种方法非常有用——比如存储以后需要用于支付的信用卡号码,或者发送接收者必须阅读的信息。

而哈希则不同。它是一个单向过程:你将数据输入系统后,会得到一个固定长度的字符串作为结果,而且没有任何密钥能够帮你还原原始数据。原始信息在哈希处理之后就已经消失了。

这听起来可能像是一种限制,但对于密码来说,恰恰正是我们所需要的。

想想看:当用户登录时,你并不需要知道他们的密码。你只需要验证他们输入的密码是否与他们在注册时设置的密码相匹配即可。这一切都可以通过哈希值来完成——将用户输入的密码进行哈希处理,然后与数据库中存储的哈希值进行比较,这样就完成了验证过程。你根本不需要原始密码本身。

<这就是为什么当用户选择“忘记密码”时,系统总会要求他们设置新的密码,而不会将原来的密码发送给他们。的确,通过电子邮件向用户发送原始密码可能存在安全风险,但实际的原因在于系统根本无法检索到那些密码。如果系统能够通过邮件将用户的原始密码发回来,那才真正是个危险信号——这说明系统存储密码的方式存在问题,导致密码没有得到妥善保护。

为什么单纯的哈希处理不够用

如果哈希运算是一个单向且不可逆的过程,那么这样不就足够了吗?在存储密码之前对它们进行哈希处理不就行了吗?

其实并不够。

第一个问题就是彩虹表。所谓彩虹表,其实就是预先计算好的、包含常见密码哈希值的数据库。一旦攻击者获得了你的密码数据库,他们根本不需要逆向还原这些哈希值,只需在彩虹表中查找相应的哈希值即可。如果用户的密码是“password123”,那么它的SHA-256哈希值一定是相同的,而这个哈希值很可能会存在于某个彩虹表中。

第二个问题也与此相关。如果两个用户使用相同的密码,那么他们的哈希值也会相同。因此,一旦攻击者破解了一个用户的密码,其他使用相同密码的用户们的密码也就都被破解了。在拥有数千名用户的数据库系统中,这种安全风险是极其严重的。

下面是一个实际的例子:

import hashlib

# 两个用户,使用相同的密码
password = "password123"

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

print(hash_one == hash_two)  # 每次都会输出 True

哈希运算具有确定性,相同的输入总是会产生相同的输出。这一特性在很多情况下确实很有用,但对于密码来说,却会带来严重的安全漏洞。

单纯的哈希处理虽然能在一定程度上提高安全性,但单独使用它是远远不够的。

盐值的作用

为了解决这两个问题,人们引入了所谓的盐值。不过,请注意,这里说的“盐值”并不是普通的食盐。

盐值是一段为每个密码单独生成的随机字符串。在进行哈希处理之前,需要将这个盐值与密码结合在一起,然后再对组合后的字符串进行哈希运算。

import hashlib
import os

password = "password123"

# 生成一个随机盐值
salt = os.urandom(16).hex()

# 将盐值与密码结合后进行哈希处理
salted_password = salt + password
hashed = hashlib.sha256(salted_password.encode()).hexdigest()

print(f"盐值: {salt}")
print(f>哈希值: {hashed})

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

令人惊讶的是:盐值其实并不需要保密。它可以以明文的形式与哈希值一起存储在数据库中。乍一听,这可能会让人觉得不太合理——如果攻击者获得了你的数据库,他们不也就得到了盐值吗?

但实际上这样完全没问题。盐值的作用并不是为了保密,而是为了让每个密码的哈希值都独一无二,从而使得预先计算好的彩虹表变得毫无用处。想要破解经过盐值处理的密码,攻击者就必须针对每一个用户单独进行暴力破解,而且每次破解时都必须使用特定的盐值。他们无法将已经完成的部分工作重复应用于其他用户。

即便盐值是可见的,这种方式也能显著增加攻击的成本。

为什么bcrypt算法运行速度较慢(以及这样设计的原因)

加盐技术确实解决了彩虹表攻击带来的问题。但仍然存在一个漏洞:如果攻击者获得了你的数据库,并决定使用暴力破解手段来尝试各种密码,他们只需不断猜测即可。只要用存储在数据库中的盐值对候选密码进行哈希处理,然后将其与存储的哈希值进行比较,重复这个过程即可。对于像SHA-256这样的快速哈希算法来说,现代GPU每秒能够执行数十亿次这样的对比操作。

这就是为什么使用通用哈希函数来存储密码会带来安全隐患的原因。SHA-256和MD5这类算法本来就是为提高处理速度而设计的,因此它们在验证文件完整性或生成校验和等场景中非常有用。但对于密码存储来说,这种快速性反而成了累赘。

这时,bcrypt就派上了用场。bcrypt是一种专门为降低哈希计算速度而设计的密码哈希算法。它并非因为设计缺陷或效率问题而显得缓慢,而是通过设置特定的参数来刻意降低计算复杂度的。这种算法具有所谓的“成本因子”(有时也称为“工作量因子”),该因子可以控制哈希运算所需的计算资源。

import bcrypt

password = b"password123"

# 这里设置了成本因子为12,这是一个常见的值
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))

print(hashed)

每当将成本因子增加1时,哈希运算所需的时间就会大约翻倍。如果成本因子为12,在你的服务器上进行一次哈希计算可能需要大约300毫秒的时间——对于正在登录的用户来说,这个时间长度几乎感觉不到。但对于那些试图尝试数百万种密码的攻击者而言,这种计算速度就使得他们的攻击计划变得不切实际了。

成本因子还可以根据硬件性能的提升而进行调整。2015年时还算足够慢的算法,如今可能已经不再适用了。bcrypt的出现让开发者能够在不修改算法本身的情况下,根据实际情况灵活调整哈希计算的要求。

你的数据库中实际上存储着什么

到目前为止,我们讨论的都是加盐技术和成本因子的概念。但值得欣慰的是,在bcrypt中,这些元素都被整合成了一条字符串存储在数据库中。这条字符串包含了验证密码所需的所有信息,一旦你知道了如何解读它,其实这一切都再也不是什么神秘的东西了。

下面是一个典型的bcrypt哈希值示例:

\(2a\)12$yMMbLgN9uY6J3LhorfU9iuLAUwKxyy8w42ubeL4MWy7Fh8B.CH/yO

让我们来分析一下这个哈希值的结构:

  • $2a —— 表示所使用的算法版本。这一信息用于告知身份验证库,应该使用哪个版本的bcrypt算法来生成这个哈希值。

  • $12 —— 表示成本因子。正如我们在前面讨论过的,成本因子为12意味着哈希运算被执行了2¹²次。

  • \(yMMbLgN9uY6J3LhorfU9iu —— 表示盐值。在最后一个\)符号之后的前22个字符就是盐值,它以明文形式与哈希值一起存储在数据库中。身份验证库在验证登录信息时,会读取这些盐值来进行进一步的处理。

  • LAUwKxyy8w42ubeL4MWy7Fh8B.CH/yO —— 就是最终的哈希值本身。剩余的字符就是哈希运算产生的结果。

当用户登录时,你的认证库并不需要任何额外的信息。它会直接从存储的字符串中读取算法版本、成本因子以及盐值,然后使用这些参数对用户的登录尝试进行哈希处理,并将计算结果进行比对。如果这两者相匹配,那就说明密码是正确的。

这就是为什么即使盐值从未被单独存储,bcrypt验证机制仍然能够正常工作的原因——因为从一开始,盐值就并不是被单独保存的。

总结

下次你在数据库中看到包含bcrypt信息的字符串时,你就会清楚地知道其中包含了哪些内容:算法版本、成本因子、盐值以及哈希结果,所有这些信息都被编码成一条字符串,而你的认证库完全能够理解并解析这条字符串。

但更重要的是:我们日常使用的这些库并非什么神奇的工具,它们其实是基于一些值得深入了解的概念而精心设计的系统。

了解为什么bcrypt算法效率较低、为什么即使盐值被显式存储,加盐机制仍然有效,以及为什么像SHA-256这样的快速哈希函数并不适合用于密码加密,这些知识会让你成为一个更加有意识、更有判断力的开发者。你会更明智地选择合适的成本因子,能够识别出那些实现不当的认证系统,并且也会明白:为什么使用MD5进行密码加密所导致的数据泄露会比使用bcrypt时造成的后果更为严重。

Comments are closed.