密码哈希加盐:bcrypt、scrypt 与最佳实践
密码存储的致命陷阱
在讨论如何安全存储密码之前,必须先理解为什么明文存储和简单哈希都是绝对不能接受的做法。一旦数据库泄露,明文密码意味着攻击者可以直接登录用户账户;即使你对密码做了哈希(例如 SHA-256),攻击者仍可通过彩虹表或暴力破解快速恢复出原始密码。
简单哈希的两大弱点:
- 确定性:相同的输入永远产生相同的哈希,攻击者可以预计算海量密码的哈希值。
- 速度太快:像 SHA-256 这样的通用哈希函数设计上追求高效,攻击者在一秒内能尝试数十亿次猜测。
这就是“加盐哈希”的根本出发点——用不可预测的随机数据来打乱输入,并让哈希过程变得刻意缓慢。
什么是“盐”?
盐(salt)是一串在哈希之前附加到密码上的随机数据。它的作用:
- 让相同密码产生不同哈希:即使两个用户使用相同的密码,由于盐不同,最终存储的哈希值也完全不同。
- 彻底摧毁彩虹表:预计算攻击必须针对每个盐重新生成一次彩虹表,在盐足够长且随机的情况下,成本高到不可行。
- 增加暴力破解难度:攻击者无法一次攻击所有密码,必须对每个盐-哈希对单独进行尝试。
盐本身不需要保密,通常会与哈希值一起明文存储。它的唯一要求是:对每个用户、每次设置密码时都应该是全局唯一且密码学安全的随机值(推荐长度不低于 16 字节)。
加盐的演变:从 HMAC 到专用函数
最初开发人员可能会手动拼接 SHA-256(salt + password),但这样做仍有问题:通用哈希太快,并且手动拼接可能引入安全缺陷。因此行业逐步形成了专用的“慢哈希”算法,它们内部已包含盐的处理,并刻意拉长计算时间。
bcrypt —— 久经考验的盾牌
bcrypt 基于 Blowfish 分组密码的密钥初始化过程,专门为密码哈希场景设计。它的核心特征包括:
- 内置盐:bcrypt 自动生成并包含盐,输出格式类似
$2b$10$...,其中包含了盐和成本因子。 - 成本因子(work factor):又称轮数(rounds),控制哈希计算的迭代次数。代价呈指数增长:每增加 1,计算时间翻倍。
- 抗 GPU/ASIC:bcrypt 的内存访问模式会增加并行化硬件的实现难度,虽然已被部分破解,但在适度成本下依然非常安全。
bcrypt 的输出格式通常如下:
$2b$12$Kq1Xe6N7OB9t5RlY8yGqf.eTfY4n1bVkLmWjXzZyO9PqRstU2VwiG
2b表示算法版本12是成本因子(2^12 = 4096 轮迭代)- 接下来的 22 个字符是盐的 Base64 编码
- 最后 31 个字符是哈希值
如何选择合适的成本因子?
目标是在你的生产服务器上,单次哈希验证时间约为 250–500 毫秒。这个时长对用户体验几乎没有影响,但对攻击者的暴力破解来说是致命拖延。可以用简单的基准测试来决定:
import bcrypt
import time
password = b"a_test_password"
for cost in range(10, 16):
start = time.time()
bcrypt.hashpw(password, bcrypt.gensalt(cost))
print(f"Cost {cost}: {time.time() - start:.3f} seconds")
根据结果选择一个接近目标耗时的最小 cost 值。注意随着服务器硬件升级,这个成本因子后续可能需要调整,但这涉及到密码哈希的更新策略(下文会讲)。
使用示例(伪代码)
# 注册或修改密码时
plain_password = get_user_input()
cost_factor = 12
hashed = bcrypt.hashpw(plain_password, bcrypt.gensalt(cost_factor))
save_to_database(username, hashed)
# 登录验证时
hashed_from_db = fetch_hashed_password(username)
if bcrypt.checkpw(login_attempt_password, hashed_from_db):
authenticate()
else:
reject()
scrypt —— 内存硬度的壁垒
scrypt 由 Colin Percival 设计,最初用于 Tarsnap 在线备份服务。它不仅要耗费 CPU 时间,更要大量消耗内存,这是其核心优势——内存硬度(memory-hardness)。
为什么需要内存硬度?
现代攻击者使用 GPU、FPGA 甚至专用 ASIC 来大规模并行暴力破解。bcrypt 虽然增加了并行难度,但仍可在具有高速缓存的硬件上获得较高并行度。scrypt 要求每次计算都需要占用大量内存(通常数 MB 到数百 MB),这样做使得攻击者无法将数千个计算引擎塞入单个芯片,因为内存会成为紧俏资源。
scrypt 的主要参数:
- N —— CPU/内存成本因子(必须是 2 的幂,通常 16384 起)
- r —— 块大小参数(影响内存访问的连续性)
- p —— 并行化参数(可并行的独立计算线程数)
一个典型的安全参数示例:N=16384, r=8, p=1(耗内存约 16 MB)。实际应用中可根据硬件能力调高 N 或 r。
输出格式通常是 scrypt$16384$8$1$<salt>$<hash>,或者以紧凑的 base64 形式编码。
使用示例(伪代码)
# 使用 scrypt 库(如 libsodium、hashlib)
N = 16384; r = 8; p = 1
salt = generate_random_salt(16)
hashed = scrypt.hash(plain_password, salt, N, r, p, dkLen=32)
store_for_user(user_id, salt, hashed, N, r, p)
# 验证时取出所有参数,重新计算
salt, stored_hash, N, r, p = get_from_db(user_id)
candidate = scrypt.hash(login_password, salt, N, r, p, dkLen=32)
if constant_time_compare(candidate, stored_hash):
login_ok()
与 bcrypt 的比较
| 特性 | bcrypt | scrypt |
|---|---|---|
| 抗暴力破解方式 | CPU 强度(迭代轮数) | CPU + 内存强度 |
| 内存消耗 | 固定约 4 KB | 可通过参数控制,MB 级 |
| GPU/ASIC 抵抗 | 中等 | 强(除非攻击者拥有巨大内存带宽) |
| 标准化程度 | 久经考验,几乎所有语言都有成熟库 | 同样成熟,但在一些环境中不如 bcrypt 普遍 |
| 参数灵活性 | 只有一个成本因子 | 三个独立参数,调优复杂 |
两者都是极佳的选择。如果必须从零开始新项目,scrypt 的内存硬度提供了更面向未来的保护,但 bcrypt 依然是简单可靠的默认方案,并且其库更轻量、易于部署。
密码哈希的通用最佳实践
不管你选 bcrypt、scrypt 还是 Argon2(更新的竞赛冠军),这些原则都适用:
1. 永远不要手动拼接哈希
直接使用成熟、经过安全审计的库。不要用 SHA-256(password + salt) 或用循环迭代自己发明“慢哈希”。
2. 每个密码使用独立的随机盐
盐的长度至少 16 字节,由密码学安全的随机数生成器产生。切勿基于用户名、邮箱等可预测信息派生盐。
3. 设定合适的计算成本并定期评估
在部署时对目标环境进行基准测试,选择一个让单次验证耗时 0.3–0.5 秒的成本。随着硬件性能提升,应允许未来升级成本参数(见下文“哈希升级策略”)。
4. 使用恒定时间比较函数
验证哈希时,必须使用常量时间字符串比较,而不是普通 ==,以防止时序攻击。所有标准密码哈希库都会提供安全的比较函数。
# 错误:if user_input_hash == stored_hash (容易受到时序攻击)
# 正确:if bcrypt.checkpw(...) 或 hmac.compare_digest(a, b)
5. 从不限制密码长度或字符集(除非业务强制)
密码越长、越复杂越好。在哈希之前不要截断,因为现代算法可以处理非常长的输入(bcrypt 内部限制 72 字节,但你可以先用 SHA-384 预哈希再传给 bcrypt 来支持更长密码;scrypt 无此问题)。
6. 绝不在日志、错误消息中记录密码或哈希
就连验证过程也不应有任何区分“用户不存在”和“密码错误”的细节,统一返回通用的认证失败信息,防止用户枚举。
如何处理旧哈希和升级策略
你的应用可能已经使用了过时的哈希方式(如 MD5、SHA-1 甚至 bcrypt 但成本太低)。不可能瞬间让所有用户重新设置密码,推荐采用增量升级策略:
机会性再哈希(Opportunistic Re-hashing)
当用户成功登录时,你拥有明文的密码,此时可以:
- 用旧方式验证密码是否匹配。
- 若匹配,立即用更强算法和更高成本重新计算哈希。
- 将新哈希和参数更新到数据库中,旧哈希丢弃。
这样用户在一次正常登录后就无痛升级到了最新保护。
多重标记(Hash Versioning)
在存储的哈希字段中加入版本前缀,例如:
1:bcrypt$2b$12$... # 版本1:bcrypt cost 12
2:scrypt$16384$8$1$... # 版本2:scrypt
验证时根据版本号选择对应的算法;登录成功后一律重写到最新版本。这允许你平滑过渡,即使需要更换整个算法。
常见误区和问答
Q: 可以先对密码做 SHA-256 再传给 bcrypt 吗?
A: 如果为了处理超过 72 字节的长密码,可以先用 SHA-384 或 SHA-512 对原始密码做一次哈希(非加盐),然后将结果作为 bcrypt 的输入。这不会降低安全性,因为 bcrypt 仍会内部加盐和迭代。但不要添加你自己的“预加盐”,这反而可能削弱安全性。
Q: Pepper 是什么?需要吗?
A: Pepper 是一个存储在应用服务器中、不进入数据库的“全局密钥”,在哈希前附加到密码上。它的作用是,即使数据库泄露但应用服务器未攻破,攻击者仍无法暴力破解任何哈希。这是一种深度防御手段,但增加了密钥管理的复杂性。如果你能安全地存储 pepper(例如通过硬件安全模块或环境变量隔离),可以实施;否则别冒险,盐+慢哈希已经足够强大。
Q: Argon2 是最新的最佳选择,为什么还在讲 bcrypt/scrypt?
A: Argon2 确实在 2015 年密码哈希竞赛中获胜,提供了更强的抗 GPU 和侧信道能力。但 bcrypt/scrypt 仍然是千万级应用验证过的成熟选择。如果你所处的语言/框架生态对 Argon2 的支持良好,可以优选 Argon2id;否则 bcrypt/scrypt 绝无过时之嫌。
总结
密码存储安全不是一个“做完就一劳永逸”的任务,它需要理解加盐、迭代和内存消耗的组合防御。核心要点:
- 使用 bcrypt、scrypt 或 Argon2,永远不要用快速哈希。
- 自动处理盐(这些算法都会内置)。
- 根据硬件性能设定合理的成本,并用机会性再哈希保持系统随时间演进。
- 遵循恒定时间比较、不泄露差异信息等周边安全实践。
当你选择合适的算法并正确实现后,即使数据库泄露,用户的密码依然能保持极高强度的保护——这正是密码哈希加盐设计的终极目标。