为什么密码必须哈希?
用户注册时提交的密码是明文。如果数据库被拖库(SQL 注入、备份泄漏、内部人员),明文存储意味着所有用户密码瞬间暴露。由于大量用户跨站复用密码,一次泄漏会引发连锁灾难。
密码哈希的作用:即使数据库泄漏,攻击者也无法还原出原始密码,只能在有限时间内尝试暴力破解。哈希是密码安全的最后一道防线。
哈希 vs 加密
| 特性 | 哈希(Hash) | 加密(Encrypt) |
|---|---|---|
| 方向 | 单向,不可逆 | 双向,可解密 |
| 用途 | 密码存储 | 传输保密、文件加密 |
| 密钥 | 不需要 | 需要密钥 |
| 示例 | bcrypt, argon2 | AES, RSA |
密码绝不能加密存储——加密意味着密钥泄漏后全盘暴露,且密钥管理比哈希更复杂。哈希才是正解。
为什么不能用 MD5 / SHA-256?
通用哈希函数(MD5、SHA-1、SHA-256)设计目标是快——为了文件校验、数据索引的高吞吐。但"快"对密码存储是致命的:
- 现代 GPU 每秒可计算数十亿次 SHA-256
- 攻击者用普通显卡几小时就能穷举 8 位以内所有密码
- MD5 / SHA-1 已被证明存在碰撞漏洞,绝对禁用
密码哈希需要的是故意慢、消耗资源的算法,让每次哈希都代价高昂,从而把暴力破解的成本拉到不可接受的程度。
核心概念
盐值(Salt)
盐是一段随机字符串,与密码拼接后再哈希:
hash = bcrypt(password + salt, cost)
盐的作用:让相同密码产生不同哈希。
| 场景 | 无盐 | 有盐 |
|---|---|---|
两个用户密码都是 123456 |
哈希相同,可批量破解 | 哈希不同,必须逐个破解 |
| 彩虹表攻击(预计算的哈希表) | 直接命中 | 失效,需重新计算 |
| 数据库泄漏 | 一次破解波及所有相同密码 | 每个用户独立 |
盐的要求:
- 每个用户唯一(用用户 ID 不够,应用 CSPRNG 生成的随机字节)
- 足够长(至少 16 字节 / 128 位)
- 不需要保密(与哈希一起存储即可)
现代密码哈希库(bcrypt、argon2)会自动生成盐并嵌入输出中,无需手动管理。
工作因子(Work Factor / Cost)
工作因子控制哈希计算的迭代次数或资源消耗:
- bcrypt 的
cost参数:2^cost次迭代 - argon2 的
time/memory/parallelism参数 - scrypt 的
N/r/p参数
核心权衡:
- 工作因子越高 → 暴力破解越慢 → 越安全
- 工作因子越高 → 每次登录验证越慢 → 用户体验下降、服务器负载上升
推荐基准:单次哈希耗时约 250ms ~ 500ms。用户登录时多等半秒可接受,但攻击者穷举一个 8 位密码需要数十年。
随着硬件进步,工作因子应定期上调(见下文迁移策略)。
内存硬度(Memory Hardness)
传统哈希(bcrypt)主要消耗 CPU。但攻击者可以用 ASIC、FPGA、GPU 专用硬件大规模并行加速,成本低廉。
内存硬度要求哈希过程占用大量内存(如 64MB),使得:
- 通用 CPU 与 GPU 内存充足,正常工作
- ASIC / FPGA 要内置大容量显存才能并行,硬件成本呈指数上升
- 攻击者无法用廉价硬件大规模并行破解
argon2 和 scrypt 都是内存硬算法,这是它们比 bcrypt 更适合新项目的原因。
三大算法详解
bcrypt
诞生于 1999 年,至今仍是使用最广泛的密码哈希算法。
原理:基于 Blowfish 加密算法的 key derivation,通过 cost 参数控制 2^cost 轮迭代。
参数:
cost:4 ~ 31,推荐 12 ~ 14salt:自动生成 16 字节
输出格式:
$2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
$2b$:算法标识(2a/2b/2y均为 bcrypt 变体,2b是修正版)12:cost 因子- 接下来 22 字符是 Base64 编码的盐
- 最后 31 字符是 Base64 编码的哈希
优点:
- 久经考验,经过 25 年实战检验
- 库支持广泛(几乎所有语言都有)
- 抗 ASIC 能力优于 SHA 系列(但仍不如 argon2)
缺点:
- 非 内存硬,GPU 并行破解仍有效
- 密码上限 72 字节(超出部分被截断,现代长密码场景有隐患)
- cost 上限 31,长远可能不够
代码示例:
// Node.js
const bcrypt = require('bcrypt');
const cost = 12;
const hash = await bcrypt.hash('userPassword123', cost);
// $2b$12$...
const match = await bcrypt.compare('userPassword123', hash);
# Python
import bcrypt
hash = bcrypt.hashpw(b'userPassword123', bcrypt.gensalt(rounds=12))
match = bcrypt.checkpw(b'userPassword123', hash)
argon2
2015 年 Password Hashing Competition 冠军,当前最推荐的密码哈希算法。
有三个变体:
- argon2id(推荐):混合型,兼顾抗侧信道攻击与抗 GPU
- argon2i:抗侧信道攻击优先,适合密钥派生
- argon2d:抗 GPU 优先,但有侧信道风险
参数:
memory:内存消耗(KB),推荐 65536(64MB)或更高time:迭代次数,推荐 2 ~ 3parallelism:并行线程数,推荐 2 ~ 4salt:至少 16 字节
输出格式:
$argon2id$v=19$m=65536,t=3,p=4$c2FsdHlzYWx0...$hashbytes...
优点:
- 内存硬度,有效抵御 ASIC / GPU 并行攻击
- 三维参数(内存 + 时间 + 并行),调优灵活
- 现代设计,无 72 字节限制
- 是 OWASP、NIST 当前首推算法
缺点:
- 相对年轻(但已被广泛审计)
- 部分老旧系统/语言库支持不全
推荐参数(OWASP 2024):
argon2id
memory: 19 MiB (19456 KB)
time: 2
parallelism: 1
更高安全需求可提升到 memory=64MB, time=3, parallelism=4。
代码示例:
// Node.js (argon2 库)
const argon2 = require('argon2');
const hash = await argon2.hash('userPassword123', {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
const match = await argon2.verify(hash, 'userPassword123');
# Python (argon2-cffi)
from argon2 import PasswordHasher, Type
ph = PasswordHasher(
time_cost=3,
memory_cost=65536,
parallelism=4,
type=Type.ID
)
hash = ph.hash('userPassword123')
match = ph.verify(hash, 'userPassword123')
scrypt
2009 年由 Colin Percival 设计,是第一个主流的内存硬 KDF。
参数:
N:CPU/内存成本参数,必须是 2 的幂,推荐 2^17 = 131072r:块大小,推荐 8p:并行参数,推荐 1 ~ 4
优点:
- 内存硬设计,优于 bcrypt
- 算法成熟,库支持较好
缺点:
- 参数调优比 argon2 复杂
- 在同等安全级别下,argon2id 通常更高效
- 已不再是 OWASP 首推
代码示例:
// Node.js
const scrypt = require('scrypt-js');
// scrypt 同步 API 较复杂,推荐用基于 Promise 的封装
# Python (hashlib.scrypt, 标准库自带)
import hashlib, os, base64
salt = os.urandom(16)
hash = hashlib.scrypt(
b'userPassword123',
salt=salt,
n=2**17,
r=8,
p=1,
dklen=32
)
# 存储: base64(salt) + '$' + base64(hash)
三者对比
| 维度 | bcrypt | scrypt | argon2id |
|---|---|---|---|
| 发布年份 | 1999 | 2009 | 2015 |
| 内存硬度 | ❌ | ✅ | ✅ |
| 抗 GPU/ASIC | 中 | 强 | 最强 |
| 密码长度限制 | 72 字节 | 无 | 无 |
| 参数维度 | 1(cost) | 3(N/r/p) | 3+(m/t/p) |
| 库支持 | 最广 | 较广 | 广 |
| OWASP 推荐 | 可用 | 可用 | 首选 |
| 单次哈希耗时(典型配置) | ~250ms | ~300ms | ~300ms |
| 抗侧信道 | 是 | 否(d)/ 是(i 变体无) | 是(id 兼具) |
如何选择?
- 新项目:直接用 argon2id,这是当前最优解
- 已有 bcrypt 且无迁移压力:可继续使用,但下次重构应迁移到 argon2id
- 已有 MD5 / SHA 系列 / 自制算法:立即迁移,这是高危漏洞
- 受限环境(嵌入式、无 argon2 库):用 bcrypt,cost ≥ 12
迁移策略
场景一:从弱哈希(MD5/SHA)迁移到 bcrypt/argon2
核心思路:懒迁移(Lazy Migration)——用户下次登录时升级哈希,无需批量重算。
1. 用户登录,提交明文密码
2. 服务端用旧算法(如 MD5)计算,与数据库比对
- 不匹配 → 拒绝登录
- 匹配 → 进入第 3 步
3. 用新算法(如 argon2id)重新哈希明文密码
4. 更新数据库中的哈希字段
5. 标记该用户已迁移(如 hash_version 字段)
数据库结构:
ALTER TABLE users ADD COLUMN hash_version INT DEFAULT 0;
-- 0 = MD5(待迁移),1 = argon2id(已迁移)
伪代码:
def verify_password(user, plaintext):
if user.hash_version == 0:
# 旧算法验证
if md5(plaintext) != user.password_hash:
return False
# 迁移到 argon2id
user.password_hash = argon2_hash(plaintext)
user.hash_version = 1
db.commit()
return True
elif user.hash_version == 1:
return argon2_verify(user.password_hash, plaintext)
优点:
- 不阻塞,无需批量重算所有密码
- 逐步迁移,老用户登录后自动升级
- 长期不登录的用户,旧哈希仍可验证
注意:对长期未登录的账户,可考虑强制密码重置。
场景二:提升 bcrypt cost 因子
随着硬件变快,老的 cost 值不再安全。同样用懒迁移:
def verify_password(user, plaintext):
if bcrypt_check(plaintext, user.password_hash):
# 检查 cost 是否需要升级
current_cost = extract_cost(user.password_hash)
if current_cost < TARGET_COST:
user.password_hash = bcrypt_hash(plaintext, TARGET_COST)
db.commit()
return True
return False
场景三:从 bcrypt 迁移到 argon2id
def verify_password(user, plaintext):
if user.hash_version == 0:
# bcrypt 验证
if not bcrypt_check(plaintext, user.password_hash):
return False
# 升级到 argon2id
user.password_hash = argon2_hash(plaintext)
user.hash_version = 1
db.commit()
elif user.hash_version == 1:
return argon2_verify(user.password_hash, plaintext)
return True
迁移注意事项
- 保留旧哈希直到迁移完成——删除前确保所有活跃用户已迁移
- 监控迁移进度——统计
hash_version分布,跟踪未迁移用户数 - 通知长期未登录用户——发邮件引导登录以触发迁移,或要求重置密码
- 灰度发布——先在小范围验证迁移逻辑正确性,避免误伤用户
- 回滚预案——保留旧哈希字段,万一新算法有问题可回退
实现要点与常见坑
1. 使用 CSPRNG 生成盐
# ❌ 不要用 random 模块
import random
salt = str(random.randint(0, 999999))
# ✅ 用密码学安全的随机数
import os
salt = os.urandom(16)
bcrypt / argon2 库的 hash() 方法会自动用 CSPRNG 生成盐,无需手动处理。
2. 常量时间比较
比较哈希时必须用常量时间函数,防止时序攻击:
# ❌ 普通 == 比较,可通过耗时推断
if user_hash == input_hash:
# ✅ 常量时间比较
import hmac
if hmac.compare_digest(user_hash, input_hash):
bcrypt / argon2 库的 verify() 内部已是常量时间实现,直接调用即可。
3. 密码长度上限
- bcrypt:72 字节截断。若用户密码超长,建议先 SHA-256 再 bcrypt(但会丢失 bcrypt 对超长密码的强度,需评估)
- argon2 / scrypt:无实际限制
4. 并发与性能
密码哈希是 CPU 密集型操作:
- 登录请求会占用 CPU ~250ms,高并发下可能成为瓶颈
- 限制登录接口速率(如每 IP 每分钟 10 次),防暴力破解耗尽资源
- 考虑异步队列处理批量哈希任务
5. 错误信息不要泄漏
# ❌ 泄漏用户是否存在
if user not found:
return "用户不存在"
if password wrong:
return "密码错误"
# ✅ 统一错误信息
return "用户名或密码错误"
6. 存储格式
推荐把算法、参数、盐都编码进单个字符串,便于迁移与多算法共存:
$argon2id$v=19$m=65536,t=3,p=4$base64salt$base64hash
$2b$12$22charsalt31charhash
解析时根据前缀判断算法,分别调用对应验证逻辑。
总结
| 维度 | 最佳实践 |
|---|---|
| 新项目算法 | argon2id(OWASP 首推) |
| 存量 bcrypt | 可保留,下次重构迁移 |
| MD5 / SHA / 明文 | 立即迁移,高危 |
| 盐 | 每用户唯一,CSPRNG 生成,≥16 字节 |
| 工作因子 | 单次哈希 ~250-500ms |
| 内存硬度 | argon2 / scrypt 优于 bcrypt |
| 迁移方式 | 懒迁移(登录时升级) |
| cost 调优 | 定期评估,随硬件提升而上调 |
| 比较方式 | 常量时间比较 |
| 错误信息 | 统一"用户名或密码错误" |
密码哈希是账户安全的基石。选对算法、设好参数、做好迁移,才能在数据库泄漏时真正保护用户。永远不要自己发明哈希算法——用经过审计的标准库,跟随 OWASP 的最新建议。