选择正确的密码哈希算法是任何处理用户凭证的应用中最关键的安全决策之一。错误的选择可能在数据泄露中暴露数百万个密码,而正确的算法可以使暴力破解在经济上不可行。本综合指南比较了三种领先的密码哈希算法 — Bcrypt、Scrypt 和 Argon2 — 帮助你为项目做出明智的决策。
为什么密码哈希很重要
密码哈希是一种单向转换,将明文密码转换为固定长度的字符串。与加密不同,哈希在设计上是不可逆的 — 你无法从哈希值恢复原始密码。当用户登录时,系统会对提交的密码进行哈希处理,并与存储的哈希值进行比较。
以明文存储密码或使用 MD5、SHA-256 等快速通用哈希函数是极其不安全的。现代 GPU 每秒可以计算数十亿次 SHA-256 哈希,这意味着攻击者凭借窃取的数据库可以在数小时或数天内破解大多数密码。
专用密码哈希算法通过故意设计得缓慢且资源密集来解决这个问题。它们包含三种关键防御:
- Salt: 加盐(Salting):在哈希之前为每个密码添加一个随机值,确保相同的密码产生不同的哈希值。
- Cost: 成本因子:一个可配置的工作参数,控制哈希计算的速度,允许你随着硬件改进而增加难度。
- Memory: 内存硬度:某些算法需要大量 RAM,使得使用专用硬件(GPU、ASIC、FPGA)的攻击成本大大增加。
# Comparison: General-purpose vs Password hashing speed
SHA-256: ~10,000,000,000 hashes/sec (GPU) ← Dangerously fast
MD5: ~25,000,000,000 hashes/sec (GPU) ← Even worse
Bcrypt: ~ 50,000 hashes/sec (GPU) ← 200,000x slower
Argon2id: ~ 1,000 hashes/sec (GPU) ← 10,000,000x slower (with 19MB memory)Bcrypt:久经考验的标准
Bcrypt 的工作原理
Bcrypt 由 Niels Provos 和 David Mazieres 于 1999 年设计,基于 Blowfish 密码。它使用成本因子(工作因子)来确定迭代次数:哈希计算运行 2^cost 轮昂贵的密钥设置阶段。默认成本因子为 10(1,024 轮),但现代建议使用 12-14(4,096-16,384 轮)。
bcrypt 哈希字符串包含验证所需的一切信息:
$2b$12$LJ3m4ys3Lg2VBe8EWdKFTe8x7N3fWs0YhW1qDZGhIJFG3rJ1/Bxiy
││ ││ └────────────────────── Hash (31 chars, Base64)
││ │└──────────────────────── Salt (22 chars, Base64, 128-bit)
││ └───────────────────────── Cost factor (12 = 2^12 = 4,096 rounds)
│└──────────────────────────── Version (2b = current)
└───────────────────────────── Algorithm identifier ($2b$ = bcrypt)Bcrypt 优点
- 经过充分验证:25 年以上的密码学审查,未发现实际可行的攻击。
- 内置盐值:自动为每个哈希生成并存储 128 位随机盐值。
- 自适应成本:工作因子可以随着硬件变快而逐步增加。
- 通用库支持:几乎在所有编程语言和框架中都可用。
- API 简单:易于正确使用,减少实现错误的可能性。
Bcrypt 缺点
- 仅 CPU 硬度:Bcrypt 不具备内存硬度,容易受到基于 GPU 和 ASIC 的攻击(虽然仍然成本较高)。
- 72 字节密码限制:Bcrypt 会截断超过 72 字节的密码。这在实践中很少有影响,但是一个设计限制。
- 固定内存使用:无论成本因子如何,仅使用 4KB 的 RAM,不会增加对并行攻击的抵抗力。
- 无并行参数:无法利用多核 CPU 进行合法哈希,同时保持对攻击者的单线程限制。
何时使用 Bcrypt
Bcrypt 是大多数应用的优秀默认选择。当你需要一个经过验证的、广泛支持的算法,且你的威胁模型不需要内存硬度时,请使用它。它是最安全的"不想过多考虑"的选择。
Scrypt:内存硬度先驱
Scrypt 的工作原理
Scrypt 由 Colin Percival 于 2009 年为 Tarsnap 在线备份服务设计。其关键创新是内存硬度:该算法需要与其难度设置成正比的大量 RAM。这使得在 GPU、ASIC 和 FPGA 上的并行攻击成本显著增加,因为每个并行实例都需要自己的内存块。
Scrypt 有三个可配置参数:
- N: N(CPU/内存成本):确定内存和 CPU 成本。必须是 2 的幂。N 越高意味着更多内存和时间。常用值:2^14 (16,384) 到 2^20 (1,048,576)。
- r: r(块大小):控制顺序内存读取大小。典型值:8。增加 r 会线性增加内存使用。
- p: p(并行度):并行链的数量。典型值:1。增加 p 允许在防御方并行化计算。
# Scrypt memory calculation
Memory = 128 * N * r bytes
N = 2^14 (16384), r = 8:
128 * 16384 * 8 = 16,777,216 bytes = 16 MB per hash
N = 2^15 (32768), r = 8:
128 * 32768 * 8 = 33,554,432 bytes = 32 MB per hash
N = 2^20 (1048576), r = 8:
128 * 1048576 * 8 = 1,073,741,824 bytes = 1 GB per hash内存使用公式:128 * N * r 字节。当 N=2^14 且 r=8 时,即 128 * 16384 * 8 = 16 MB 每次哈希。
Scrypt 优点
- 内存硬度:每次哈希需要大量 RAM,使 GPU/ASIC 攻击成本大大增加。
- 可配置参数:对 CPU 成本、内存成本和并行度的独立控制。
- 生产环境验证:自 2011 年以来被 Litecoin、Dogecoin 和许多加密货币系统使用。
- 良好的安全余量:15 年以上未发现实际可行的攻击。
Scrypt 缺点
- 参数调整复杂:三个相互依赖的参数(N、r、p)使配置比 bcrypt 更困难。
- 时间-内存权衡漏洞:攻击者可以通过花费更多 CPU 时间来减少内存需求,部分破坏了内存硬度保证。
- 内置库较少:不像 bcrypt 那样在所有语言和框架中普遍支持。
- 无侧信道抵抗:未专门设计来抵抗缓存时序攻击。
何时使用 Scrypt
当你需要内存硬度且 Argon2 在你的环境中不可用时,使用 Scrypt。对于需要使基于 GPU 的破解成本显著增加的应用,它是一个可靠的选择。
Argon2 (Argon2id):现代黄金标准
Argon2 的工作原理
Argon2 在 2015 年赢得了密码哈希竞赛(PHC),这是一项为期多年的公开竞赛,旨在找到最佳密码哈希算法。它由 Alex Biryukov、Daniel Dinu 和 Dmitry Khovratovich 设计。Argon2 有三个变体:
Argon2 有三个主要参数:
- m: m(内存成本):内存量(KiB)。OWASP 建议 Argon2id 至少使用 19 MiB(19456 KiB)。越高越好。
- t: t(时间成本/迭代次数):对内存的遍历次数。Argon2id 最少 2 次。更多迭代 = 更慢的哈希。
- p: p(并行度):线程数。设置为可用于哈希的 CPU 核心数。典型值:1-4。
# Argon2id hash format
$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHRzYWx0$W1BLEn4OWxCjU2F...
││ ││ ││ ││ ││ └── Hash (Base64)
││ ││ ││ ││ │└── Salt (Base64)
││ ││ ││ ││ └──── p=1 (parallelism)
││ ││ ││ │└────────── t=2 (iterations)
││ ││ ││ └─────────── m=19456 (memory in KiB = 19 MiB)
││ ││ │└─────────────────── v=19 (version 1.3)
││ │└──┘──────────────────── argon2id (variant)
└┘────────┘──────────────────────── Algorithm identifierArgon2 优点
- PHC 获胜者:经过全球密码学社区在多年竞赛中的严格评估。
- 卓越的内存硬度:没有已知的时间-内存权衡攻击(不像 scrypt)。
- 侧信道抵抗:Argon2id 混合模式同时提供 GPU 抵抗和侧信道抵抗。
- 三个独立参数:对内存、时间和并行度的细粒度控制。
- 现代设计:汲取了 bcrypt 和 scrypt 缺点的经验教训。
- OWASP 推荐:OWASP 密码存储备忘录中的首选推荐。
Argon2 缺点
- 较年轻的算法:仅从 2015 年开始,因此比 bcrypt(1999)少了很多年的实际部署经验。
- 库可用性:不像 bcrypt 在所有语言和框架中那样普遍支持,尽管采用率正在快速增长。
- 参数复杂性:与 scrypt 类似,调整三个参数需要对部署环境有一定了解。
- 内存分配开销:每次哈希分配和释放大块内存可能影响高吞吐量场景的性能。
何时使用 Argon2
如果你的语言或框架有成熟的库,Argon2id 是新应用的推荐选择。它提供最佳的整体安全属性。如果你今天正在构建任何存储密码的系统,Argon2id 应该是你的首选。
并排比较
| 特性 | Bcrypt | Scrypt | Argon2id |
|---|---|---|---|
| 推出年份 | 1999 | 2009 | 2015 |
| 内存硬度 | 无(固定 4KB) | 是(可配置) | 是(可配置) |
| 侧信道抵抗 | 不适用 | 否 | 是(混合模式) |
| GPU/ASIC 抵抗力 | 中等 | 高 | 最高 |
| 可配置参数 | 1(成本因子) | 3(N、r、p) | 3(m、t、p) |
| 最大密码长度 | 72 字节 | 无限制 | 无限制 |
| 库采用度 | 通用 | 良好 | 快速增长中 |
| OWASP 推荐 | 可接受(第二选择) | 可接受(第三选择) | 首选推荐 |
| 配置复杂度 | 非常低 | 中等 | 中等 |
| 哈希速度(默认) | ~300ms(cost=12) | ~100ms(N=2^14) | ~200ms(19MiB,t=2) |
代码示例
Node.js 示例
// ============================================
// Bcrypt (Node.js) - using 'bcrypt' package
// ============================================
const bcrypt = require('bcrypt');
// Hash a password
const saltRounds = 12; // Cost factor: 2^12 = 4,096 iterations
const password = 'mySecurePassword123';
// Async (recommended)
const hash = await bcrypt.hash(password, saltRounds);
// => "$2b$12$LJ3m4ys3Lg2VBe8EWdKFTe..."
// Verify a password
const isMatch = await bcrypt.compare(password, hash);
// => true
// ============================================
// Scrypt (Node.js) - built-in crypto module
// ============================================
const crypto = require('crypto');
// Hash a password
const salt = crypto.randomBytes(16);
const N = 32768; // CPU/memory cost (2^15)
const r = 8; // Block size
const p = 1; // Parallelism
const keyLen = 64; // Output length in bytes
const scryptHash = crypto.scryptSync(password, salt, keyLen, { N, r, p });
// Store both salt and hash (e.g., as hex strings)
const stored = salt.toString('hex') + ':' + scryptHash.toString('hex');
// Verify: split stored value, re-derive, and compare
const [storedSalt, storedHash] = stored.split(':');
const derivedHash = crypto.scryptSync(
password,
Buffer.from(storedSalt, 'hex'),
keyLen,
{ N, r, p }
);
const isValid = crypto.timingSafeEqual(
Buffer.from(storedHash, 'hex'),
derivedHash
);
// ============================================
// Argon2 (Node.js) - using 'argon2' package
// ============================================
const argon2 = require('argon2');
// Hash a password (Argon2id is the default)
const argon2Hash = await argon2.hash(password, {
type: argon2.argon2id, // Use Argon2id variant
memoryCost: 19456, // 19 MiB (OWASP minimum)
timeCost: 2, // 2 iterations
parallelism: 1, // 1 thread
});
// => "$argon2id$v=19$m=19456,t=2,p=1$..."
// Verify a password
const isArgon2Match = await argon2.verify(argon2Hash, password);
// => true
// Check if hash needs rehashing (e.g., after config change)
const needsRehash = argon2.needsRehash(argon2Hash, {
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});Python 示例
# ============================================
# Bcrypt (Python) - using 'bcrypt' package
# ============================================
import bcrypt
password = b"mySecurePassword123"
# Hash a password
salt = bcrypt.gensalt(rounds=12) # Cost factor 12
hashed = bcrypt.hashpw(password, salt)
# => b"$2b$12$LJ3m4ys3Lg2VBe8EWdKFTe..."
# Verify a password
is_valid = bcrypt.checkpw(password, hashed)
# => True
# ============================================
# Scrypt (Python) - using hashlib (built-in)
# ============================================
import hashlib
import os
import hmac
password = b"mySecurePassword123"
salt = os.urandom(16)
# Hash a password
scrypt_hash = hashlib.scrypt(
password,
salt=salt,
n=32768, # CPU/memory cost (2^15)
r=8, # Block size
p=1, # Parallelism
dklen=64 # Output length
)
# Store salt + hash together
stored = salt.hex() + ":" + scrypt_hash.hex()
# Verify: re-derive and use constant-time comparison
stored_salt, stored_hash = stored.split(":")
derived = hashlib.scrypt(
password,
salt=bytes.fromhex(stored_salt),
n=32768, r=8, p=1, dklen=64
)
is_valid = hmac.compare_digest(
bytes.fromhex(stored_hash), derived
)
# ============================================
# Argon2 (Python) - using 'argon2-cffi' package
# ============================================
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=2, # 2 iterations
memory_cost=19456, # 19 MiB
parallelism=1, # 1 thread
hash_len=32, # Output hash length
salt_len=16, # Salt length
)
# Hash a password
argon2_hash = ph.hash("mySecurePassword123")
# => "$argon2id$v=19$m=19456,t=2,p=1$..."
# Verify a password
try:
ph.verify(argon2_hash, "mySecurePassword123")
print("Password is correct")
except Exception:
print("Password is incorrect")
# Check if hash needs rehashing
if ph.check_needs_rehash(argon2_hash):
# Re-hash with current parameters
new_hash = ph.hash("mySecurePassword123")
# Update stored hash in database推荐决策指南
使用此决策指南为你的项目选择正确的算法:
┌─────────────────────────────────────────────────────────────┐
│ Password Hashing Algorithm Decision Guide │
└─────────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────┐
│ Is Argon2id available with │
│ a mature library in your │
│ language / framework? │
└──────┬────────────┬───────────┘
│ │
YES NO
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ Use Argon2id │ │ Do you need memory │
│ m=19456 (19MB) │ │ hardness for GPU/ASIC │
│ t=2, p=1 │ │ resistance? │
└─────────────────┘ └──────┬──────────┬─────────┘
│ │
YES NO
│ │
▼ ▼
┌───────────────┐ ┌─────────────────┐
│ Use Scrypt │ │ Use Bcrypt │
│ N=2^15, r=8 │ │ cost factor=12+ │
│ p=1 │ │ │
└───────────────┘ └─────────────────┘是:使用 Argon2id,采用 OWASP 推荐设置(m=19456,t=2,p=1)。
否:继续下一个问题。
是:使用 Scrypt,N=2^15,r=8,p=1(如果服务器能承受,可使用更高的 N)。
否:使用 Bcrypt,成本因子 12 或更高。
不要强制所有用户重置密码。而是包装旧哈希:Argon2id(existing_sha256_hash)。在下次登录时,直接用 Argon2id 重新哈希。
总结:对于 2024 年以后的新项目,使用 Argon2id。对于已使用 bcrypt 的现有项目,没有紧迫的迁移需要 — bcrypt 仍然安全。如果你在 bcrypt 和 scrypt 之间选择,bcrypt 更简单,对大多数威胁模型同样安全。
OWASP 推荐设置
OWASP 密码存储备忘录提供了以下最低推荐配置:
| 算法 | 推荐配置 | 说明 |
|---|---|---|
| Argon2id | m=19456 (19 MiB), t=2, p=1 | Primary recommendation. Increase m if server RAM allows. |
| Bcrypt | cost=12 (minimum 10) | Second choice. Increase cost factor to target 200-500ms. |
| Scrypt | N=2^17 (131072), r=8, p=1 | Third choice. Memory = 128 * N * r = 128 MB per hash. |
算法间迁移
如果你需要在不强制密码重置的情况下从一种哈希算法迁移到另一种,使用"包装并升级"策略:
// Migration example: bcrypt → Argon2id (Node.js)
const bcrypt = require('bcrypt');
const argon2 = require('argon2');
async function verifyAndUpgrade(password, storedHash, userId) {
// Check hash version
if (storedHash.startsWith('$argon2id$')) {
// Already using Argon2id — verify directly
return await argon2.verify(storedHash, password);
}
if (storedHash.startsWith('$2b$')) {
// Still using bcrypt — verify with bcrypt
const isValid = await bcrypt.compare(password, storedHash);
if (isValid) {
// Upgrade: re-hash directly with Argon2id
const newHash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
// Update database
await db.query(
'UPDATE users SET password_hash = $1, hash_version = $2 WHERE id = $3',
[newHash, 'argon2id', userId]
);
}
return isValid;
}
throw new Error('Unknown hash format');
}Important: 重要:在迁移过程中绝对不要存储明文密码,即使是临时的。包装方法确保所有哈希始终至少受到一种强算法的保护。
常见实现陷阱
全局盐值(所有密码使用相同的盐)会使盐值失去作用。如果一个密码被破解,所有相同的密码都会立即暴露。三种算法(bcrypt、scrypt、Argon2)默认都会为每个哈希生成唯一的随机盐值 — 不要覆盖这个行为。
使用最低成本因子会使哈希过快。目标是在生产硬件上每次哈希 200-500ms。在实际服务器上进行基准测试,并增加成本因子直到达到目标延迟。
Pepper(在哈希之前添加到密码中的服务端密钥)可以提供纵深防御,但前提是安全存储在数据库之外(例如硬件安全模块或环境变量中)。如果 pepper 与数据库一起泄露,则不提供任何好处。
随着硬件改进,你应该定期增加成本因子。当用户成功登录时,检查其哈希是否使用了过时的成本因子,并使用当前设置重新哈希。
验证哈希时始终使用恒定时间比较函数。所有信誉良好的密码哈希库都包含此功能,但如果你手动比较哈希,请使用专用的恒定时间比较函数。
性能基准测试
这些基准测试在标准 4 核服务器 CPU(Intel Xeon E-2236,3.4GHz)、16GB RAM 上测量。实际性能因硬件而异。
| 算法与配置 | 每次哈希时间 | 每次哈希内存 | 攻击者速率(RTX 4090) |
|---|---|---|---|
| Bcrypt (cost=10) | ~75ms | 4 KB | ~150K hashes/sec |
| Bcrypt (cost=12) | ~300ms | 4 KB | ~38K hashes/sec |
| Bcrypt (cost=14) | ~1.2s | 4 KB | ~9.5K hashes/sec |
| Scrypt (N=2^14, r=8) | ~100ms | 16 MB | ~5K hashes/sec |
| Scrypt (N=2^15, r=8) | ~200ms | 32 MB | ~2.5K hashes/sec |
| Scrypt (N=2^17, r=8) | ~800ms | 128 MB | ~300 hashes/sec |
| Argon2id (19MiB, t=2) | ~200ms | 19 MB | ~1K hashes/sec |
| Argon2id (64MiB, t=3) | ~800ms | 64 MB | ~150 hashes/sec |
| Argon2id (256MiB, t=4) | ~3.5s | 256 MB | ~20 hashes/sec |
常见问题
Bcrypt 在 2024 年还安全吗?
是的,使用成本因子 12 或更高时,bcrypt 仍然被认为是安全的。在超过 25 年的时间里,没有发现针对 bcrypt 的实际可行攻击。然而,对于新项目,Argon2id 是首选,因为它通过内存硬度提供了对基于 GPU 攻击的额外防御。
如果我使用 SHA-256 进行密码哈希会怎样?
SHA-256 是一种为速度设计的通用哈希函数,不适合密码哈希。现代 GPU 每秒可以计算超过 100 亿次 SHA-256 哈希。这意味着攻击者可以在数小时内暴力破解大多数密码。始终使用专用的密码哈希算法(bcrypt、scrypt 或 Argon2)。
如何选择正确的成本因子/工作参数?
目标是使每次哈希在生产服务器上耗时 200-500 毫秒。从推荐的默认值开始,在实际硬件上进行基准测试。增加参数直到达到目标延迟。请记住,登录端点通常不需要每秒处理数千个请求,因此你可以承受更昂贵的哈希。
Argon2 除了密码还能用于其他方面吗?
是的,Argon2 可用于密钥派生(从密码派生加密密钥)、工作量证明系统和加密货币挖矿。Argon2d 变体专为侧信道攻击不是关注点的用例设计,例如加密货币挖矿。
除了盐之外,还应该添加 pepper 吗?
Pepper(存储在数据库之外的密钥)可以提供纵深防御。如果攻击者仅获得数据库而没有获得应用服务器或密钥管理系统,pepper 可以防止离线破解。然而,它增加了复杂性,必须谨慎管理(密钥轮换、安全存储)。对于大多数应用,配置良好的 Argon2id 哈希和强成本因子就已足够。
应该多久增加一次成本因子?
每年或在升级服务器硬件时审查密码哈希参数。摩尔定律表明 CPU 性能大约每 18 个月翻一番,这意味着你应该大约每 18-24 个月将成本因子增加 1(将工作量翻倍)。实施"哈希升级" — 当用户成功登录时,使用新的成本因子重新哈希密码。