如果你使用AES、RSA,甚至SHA-256来存储用户密码,那你是在做错事。不是轻微的错误——而是根本性的错误。这是网络开发中最常见的安全错误,而它的解决方案很简单:使用像bcrypt这样的专用密码哈希函数。
下面将解释其重要性、bcrypt的工作原理以及生产环境下的代码示例。
为何密码需要特殊处理
大多数加密操作都是为了 可逆或快速。加密是为可逆而设计的——这是它的全部目的。SHA-256和MD5是快速的,每秒可处理数十亿字节。这两个特性对密码来说都是灾难性的。
当攻击者获取你的数据库时,他们就能获取你的密码哈希值。对于加密,如果他们找到了密钥,就能解密所有内容。而对于像MD5或SHA-256这样的快速哈希,他们可以使用GPU加速的暴力破解攻击——现代硬件每秒可测试 数百亿 个MD5哈希值。你的“复杂”密码几分钟内就会被破解。
密码需要具备:
- 不可逆 ——即使拥有密钥或算法,也无法还原明文
- 计算速度慢 ——刻意的缓慢使暴力破解变得不切实际
- 每个用户唯一 ——相同的密码必须生成不同的哈希值
bcrypt 满足所有这三个要求。通用哈希函数和加密算法均不满足。
哈希、加密与密码哈希的区别
这些不能互换:
| 方法 | 可逆? | 快速? | 适合密码安全? |
|---|---|---|---|
| 加密(AES,RSA) | 是——拥有密钥时 | 是的 | 不 |
| 快速哈希(MD5,SHA-256) | 不 | 是(设计如此) | 不 |
| 密码哈希(bcrypt,Argon2id) | 不 | 否(设计如此) | 是的 |
加密的可逆性是致命缺陷:一旦密钥泄露,所有密码都将暴露。快速哈希同样致命:速度使得暴力破解成为可能。密码哈希函数被设计为缓慢——而这正是其目的所在。
bcrypt 的工作原理
bcrypt 于1999年由Niels Provos和David Mazières设计,它有三项关键功能:
1. 加盐。 在哈希之前,bcrypt 生成一个随机的盐(16字节),并将其包含在哈希输出中。即使两个用户拥有相同的密码,它们的哈希值也不同。这完全抵御了预计算彩虹表攻击。
2. 工作因子(成本)。 bcrypt 接受一个成本参数(通常为10到14)。每次递增都会使计算时间翻倍。在成本为12时,哈希操作在现代硬件上大约需要250至400毫秒。这对于登录请求来说几乎不可察觉地缓慢——但将十亿次尝试的暴力破解变成了长达数十年的操作。
3. 输出是自包含的。 一个bcrypt哈希看起来像 $2b$12$... ,并编码了算法版本、成本因子、盐和哈希值。你不需要单独的盐字段。只需存储整个字符串。
生产代码:Node.js 和 Python
Node.js(bcryptjs 或 bcrypt)
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
// Hash a password
async function hashPassword(plaintext) {
return bcrypt.hash(plaintext, SALT_ROUNDS);
}
// Verify a password against a stored hash
async function verifyPassword(plaintext, storedHash) {
return bcrypt.compare(plaintext, storedHash);
}
// Usage
const hash = await hashPassword('hunter2');
// Store `hash` in your database
const isValid = await verifyPassword('hunter2', hash);
// true
Python(bcrypt)
import bcrypt
COST = 12
def hash_password(plaintext: str) -> bytes:
salt = bcrypt.gensalt(rounds=COST)
return bcrypt.hashpw(plaintext.encode('utf-8'), salt)
def verify_password(plaintext: str, stored_hash: bytes) -> bool:
return bcrypt.checkpw(plaintext.encode('utf-8'), stored_hash)
# Usage
hashed = hash_password('hunter2')
# Store hashed in your database
is_valid = verify_password('hunter2', hashed)
# True
存储完整的哈希字符串。永远不要存储明文,永远不要单独存储盐值,永远不要存储中间值。
选择工作因子
合适的工作因子取决于你的硬件。目标是让每次哈希操作在你的生产服务器上耗时 200–500毫秒 。这既保证了良好的用户体验,又足以让攻击者感到困扰。
当前建议: 成本12 作为最低标准, 14 对于高价值账户(管理员、财务账户)。在你的实际硬件上进行基准测试:
// Node.js: benchmark different cost factors
const bcrypt = require('bcrypt');
for (let cost = 10; cost <= 14; cost++) {
const start = Date.now();
await bcrypt.hash('benchmark', cost);
console.log(`Cost ${cost}: ${Date.now() - start}ms`);
}
如果成本12耗时少于100毫秒,就提高它。如果成本14耗时超过1000毫秒,就降低到13。每年重新评估一次——硬件会变快,你的成本因子也应随之调整。
你可以使用 IO Tools bcrypt 哈希生成器.
bcrypt 与 Argon2id 与 scrypt 的对比
bcrypt 经过实战检验且广泛支持。但它有一个局限性:它不是内存密集型的。拥有专用硬件(ASIC或FPGA)的攻击者可以比内存密集型算法更高效地并行化攻击。
| Algorithm | 内存密集型 | 抗并行攻击 | 推荐 |
|---|---|---|---|
| bcrypt | 不 | 部分 | 良好默认选项;成本≥12 |
| scrypt | 是的 | 部分 | 优于bcrypt,工具支持较少 |
| Argon2id | 是的 | 是的 | 推荐用于新项目 |
对于新项目: 使用Argon2id。它赢得了2015年的密码哈希竞赛,是内存密集型的,能抵抗GPU和ASIC攻击,并已被OWASP列为推荐方案。其API与bcrypt几乎相同。
对于现有项目: 如果你已经使用bcrypt并且成本因子合理,迁移并不紧急。可以在下一次重大重构中进行。
常见的实现错误
对哈希值再次哈希。 一些开发者会在客户端对密码进行哈希,然后再在服务器端对哈希值进行二次哈希。客户端的哈希值变成了“密码”。你现在是在哈希一个固定长度的十六进制字符串,而不是一个用户选择的密码——双重哈希不会带来任何好处,也不会带来任何收益,反而引入了混淆。
72字节截断问题。 bcrypt 会静默忽略超过72字节的内容。一个100字符的密码和一个前72字符相同的72字符密码在bcrypt中是相同的。如果用户设置了很长的密码,这将导致无声的安全降级。缓解措施:在传递给bcrypt之前,先用SHA-256进行预哈希——但前提是你要完全理解其影响,并清晰地记录说明。
工作因子过弱。 成本10在2011年是合理的。到2026年,至少应使用成本12。如果你现有的记录使用成本10,可以透明升级:在登录成功后,用新成本重新哈希已验证的密码,并存储更新后的哈希值。
异步操作很重要。 bcrypt 是CPU密集型的。在Node.js中,始终使用异步API(如上所示),以避免阻塞事件循环。在Node服务器中使用同步bcrypt会使所有其他请求等待。
从MD5/SHA迁移到bcrypt
你无法在没有原始明文的情况下重新哈希。但你可以进行机会性迁移:
- 添加一个
password_hash列,与旧password_md5列 - 在登录成功时(当你拥有明文密码),使用bcrypt对其进行哈希,并存储在
password_hash,清除旧列 - 在迁移期间,未登录的用户可以被强制重置密码
- 一旦
password_md5对所有用户为空,删除该列
这是标准做法,无需停机。
底线
加密适用于你需要检索的数据。哈希适用于你需要验证的数据。密码应被验证而非检索——这意味着加密是错误的工具。
bcrypt 提供了加盐、可配置的缓慢计算和自包含的哈希格式。它已经为25年提供了正确的解决方案。使用成本12或更高的值,新项目使用Argon2id,并尽快迁移到bcrypt,同时淘汰MD5和SHA-256。
搞错这一点不是假设风险。数据库一旦被泄露,使用bcrypt哈希的密码在计算上几乎无法被破解。而MD5哈希可以在一夜之间被破解。
