Web服务密码存储安全详解:从哈希到密钥派生的演进
1. 为什么不能存明文?
如果把用户密码以明文写入数据库,一旦服务端被拖库,所有用户的原始密码就会直接暴露。攻击者不仅可以用这些密码登录你的服务,还会尝试"撞库"------用相同邮箱+密码组合去登录其他平台。大量用户在多个站点复用同一组密码,一次明文泄露就会引发连锁安全事件。
核心原则 永远不要存储明文密码,即使是内部数据库也不应该能看到用户的原始密码。 服务端只能存储经过哈希处理后的"密码指纹"。
2. 哈希 vs 加密:本质区别
初学者常混淆这两个概念,但它们的核心区别只有一条:
| 特性 | 哈希(Hash) | 加密(Encryption) |
|---|---|---|
| 方向性 | 单向------不可逆 | 双向------可解密还原 |
| 输出长度 | 固定长度 | 与输入长度相关 |
| 密钥 | 无密钥 | 依赖密钥 |
| 目的 | 校验完整性、存储指纹 | 保证机密性、可恢复 |
哈希是不可逆 的:从 SHA-256("password123") 可以算出 ef92b...,但从 ef92b... 无法还原出 password123。而加密(如 AES)是可逆的:用密钥加密后,持有密钥就能解密还原明文。
判断标准 如果系统需要还原原始密码,那就是加密(说明设计错了)。密码存储应该只用哈希。
正因为哈希不可逆,即使数据库泄露,攻击者也无法直接拿到用户的原始密码------当然,前提是用了正确的哈希算法。
3. 哈希的演进:MD5 → SHA-1 → SHA-256 → 密码专用哈希
3.1 为什么 MD5/SHA 系列不适合存密码?
MD5、SHA-1、SHA-256 虽然都是哈希函数,但它们是为数据完整性校验 设计的(如文件校验和、数字签名),不是为密码存储设计的。问题出在它们的设计目标上:
- 速度快------SHA-256 在现代硬件上每秒可计算数十亿次,攻击者可以极低成本暴力枚举密码
- 无盐------相同的输入总是产生相同的输出,容易遭受预计算攻击
- GPU/ASIC 友好------这些算法能高效并行,攻击者可以用 GPU 集群大规模加速破解
注意 每秒 10 亿次 SHA-256 计算,意味着 8 位纯小写字母密码(约 2000 亿组合)不到 3 分钟即可穷举完毕。
3.2 彩虹表的威胁
彩虹表(Rainbow Table)是一种空间换时间的预计算攻击技术。攻击者预先算好大量常见密码的哈希值,构建一个"密码→哈希"的查找表。破解时只需查表即可还原密码,无需实时计算。
加盐是彩虹表的直接克星------每个用户使用不同的随机盐值,相同密码也会产生不同的哈希结果,预计算表完全失效。详见第 4 节。
4. 加盐(Salt):对抗预计算攻击
盐(Salt)是一段随机生成的字符串,在哈希前拼接到密码上:
ini
hash = H(password + salt)
每个用户的盐值应该唯一且随机,即使两个用户设置了完全相同的密码,最终的哈希值也不一样。
加盐解决了什么问题?
- 彩虹表失效------攻击者无法提前计算所有"密码+随机盐"的组合,必须针对每个盐值单独建表
- 批量破解受阻------即使数据库泄露,攻击者也必须对每个用户单独计算,无法一次算完所有用户
- 相同密码不可识别------两个用户用同样的密码,哈希值不同,避免了信息泄露
加盐的最佳实践:
- 盐值长度至少 16 字节 ,使用密码学安全的随机数生成器(如
SecureRandom) - 每个用户独立盐值,绝不能所有用户共用同一个盐
- 盐值可以明文存储在数据库里------它的作用是增加攻击者的计算量,不需要保密
存储格式 通常将盐值与哈希值一同存储,用
$分隔。例如 bcrypt 的格式:$2b$10$SaLtSaLtSaLtSaLtSaLtSu.HashValueHere,其中已经内嵌了盐值。
实际存储方式有两种:
- 独立盐字段 (适用于手动 SHA-256 + 盐的方式)------ user 表中有
salt和password_hash两列 - 嵌入式盐 (推荐,适用于 bcrypt / Argon2)------ 哈希字符串自身编码了所有信息,只需一个
password_hash字段。以 bcrypt 为例:
现代密码库(bcrypt、argon2)默认使用这种格式,开发者无需手动管理盐值。
5. 密码专用哈希函数(密钥派生函数)
通用哈希函数追求"快",密码专用哈希函数追求"慢且难并行"------让攻击者的每次尝试都付出高昂代价。
这类函数统称为慢哈希函数 (Slow Hash Function)或密码哈希函数 (Password Hashing Function)。其中 scrypt 和 Argon2 还属于 KDF(Key Derivation Function,密钥派生函数)------它们可以从密码中派生出指定长度的密钥,用于加密等场景。整个演进路径就是:
对比普通哈希、慢哈希和 KDF 在密码存储中的定位:
| 特性 | 普通哈希(SHA-256) | KDF(Argon2 / scrypt) |
|---|---|---|
| 输出长度 | 固定(如 256 bit) | 可配置 |
| 速度 | 极快(数十亿次/秒) | 慢(可调节) |
| 内存占用 | 极小 | 可配置(抗 ASIC) |
| 设计用途 | 数据完整性校验 | 密码存储 / 派生密钥 |
5.1 bcrypt
1999 年由 Niels Provos 和 David Mazières 基于 Blowfish 密码算法设计,至今仍是最广泛使用的密码哈希方案。
核心特点:
- 内嵌盐值(16 字节),无需开发者手动管理
- 可配置 cost 因子( 24 ~ 231),默认通常为 210 或 212
- 抗 GPU/ASIC 加速 ------ 算法内建内存访问模式不利于并行硬件
- 输出固定 60 字符,格式
$2b$10$...
** 2avs2b vs 2y∗∗bcrypt有多个版本标识。'2a
是原始版,2b'修复了一个处理0xFF字符的bug,'2y是某些第三方库的修正标记。新代码应使用$2b`。
局限: 抗 GPU 能力不如 scrypt 和 Argon2,内存占用固定(约 4 KB)。
5.2 scrypt
2009 年由 Colin Percival 设计,引入内存硬性(Memory-hard)概念------不仅要求计算量,还要求大内存。
核心特点:
- 参数:
N(CPU/内存消耗)、r(块大小)、p(并行度) - 内存消耗 = 128×N×r 字节
- 典型配置(
N=2^14, r=8, p=1)需要约 16 MB 内存 - ASIC 设计成本极高 ------ 需要大量片上内存,性价比远低于通用哈希
典型应用: 加密货币钱包(如 Litecoin 使用 scrypt)、需要强抗 ASIC 的场景。
局限: 参数配置较复杂,部分语言生态的库质量参差不齐。
5.3 Argon2id(现代推荐)
2015 年密码哈希竞赛(PHC)冠军,2019 年被 RFC 9106 标准化,是目前公认的最佳密码哈希算法。
三个变体:
| 变体 | 用途 |
|---|---|
| Argon2d | 抗 GPU/ASIC,适用于加密货币等 |
| Argon2i | 抗侧信道攻击,适用于密码哈希 |
| Argon2id | 混合模式(推荐)------ 前一半用 Argon2i,后一半用 Argon2d |
核心参数:
memory(内存开销,单位 KB)------ 如 64 MBiterations(迭代次数,也称 time cost)------ 如 3parallelism(并行线程数)------ 如 4salt(随机盐值)------ 至少 16 字节hashLength(输出长度)------ 通常 32 字节
推荐起点配置(RFC 9106):
| 场景 | memory | iterations | parallelism |
|---|---|---|---|
| 普通 Web 应用 | 64 MB | 3 | 4 |
| 高安全场景 | 128 MB | 4 | 4 |
| 嵌入式/低内存 | 16 MB | 4 | 2 |
5.4 选型建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 通用 Web 应用(Spring Boot、Django 等) | bcrypt(cost ≥ 10) | 框架内置,开箱即用,社区成熟 |
| 新项目,库已支持 Argon2 | Argon2id(64 MB, 3 iter) | 更强的抗 GPU/ASIC 能力 |
| 密码管理器、加密钱包 | Argon2id 或 scrypt | 需要极强抗暴力破解,可接受更大计算开销 |
| 嵌入式 / IoT 设备 | bcrypt(cost 适当调低) | Argon2id 的内存要求可能过高 |
6. 关键参数调优
密码哈希函数的参数需要在安全 与性能之间做权衡。参数越高越安全,但用户登录等待时间也越长。
bcrypt cost 因子如何选
cost=10 时一次哈希约 50~100 ms(现代 CPU),每增加 1 成本翻倍:
| cost | 轮数 | 约耗时(单次) | 适用场景 |
|---|---|---|---|
| 8 | 256 | ~15 ms | 极低延迟场景(内部服务) |
| 10 | 1,024 | ~60 ms | 通用 Web 应用(默认推荐) |
| 12 | 4,096 | ~250 ms | 安全敏感应用 |
| 14 | 16,384 | ~1,000 ms | 高强度场景(密码管理器等) |
选择方法: 在目标服务器硬件上压测,找到用户可接受的延迟上限(通常 ≤ 500 ms),取最大值。
Argon2id 参数如何选
三个核心参数相互影响:
- memory:增加内存让 GPU/ASIC 的性价比断崖式下降
- iterations:增加迭代提高计算量
- parallelism:利用多核加速,但攻击者也受益(需适度)
一条经验法则:在目标机器上调整参数,使一次哈希耗时在 200~500 ms,且内存 ≥ 64 MB。
7. 实际代码示例
7.1 Java(Spring Boot + BCrypt)
整个注册和登录流程可以用时序图直观理解:
Spring Security 内置了 BCryptPasswordEncoder,开箱即用:
java
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
private final UserRepository userRepository;
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
if (userRepository.findByUsername(req.username()) != null) {
return ResponseEntity.badRequest().body("用户名已存在");
}
User user = new User();
user.setUsername(req.username());
user.setPasswordHash(encoder.encode(req.password()));
// encoder.encode() 返回 $2b$12$salt$hash
// salt 和 cost 已内嵌在该字符串中, 无需单独存储
userRepository.save(user);
return ResponseEntity.ok("注册成功");
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
User user = userRepository.findByUsername(req.username());
if (user == null) {
return ResponseEntity.badRequest().body("用户不存在");
}
if (!encoder.matches(req.password(), user.getPasswordHash())) {
return ResponseEntity.badRequest().body("密码错误");
}
return ResponseEntity.ok("登录成功");
}
}
核心逻辑:
| 步骤 | 操作 |
|---|---|
| 注册 | encoder.encode(明文) → password_hash 落库 |
| 登录 | 从库查 password_hash → encoder.matches(明文, 库中哈希) |
所以 user 表的结构只需:
sql
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(60) NOT NULL -- bcrypt 固定 60 字符
);
7.2 BCryptPasswordEncoder源码
java
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
String salt = getSalt();
return BCrypt.hashpw(rawPassword.toString(), salt);
}
private String getSalt() {
if (this.random != null) {
return BCrypt.gensalt(this.version.getVersion(), this.strength, this.random);
}
return BCrypt.gensalt(this.version.getVersion(), this.strength);
}
从上面源码看出:
- cost(strength=12):已编码进结果字符串里,格式为 2a12$...,其中 12 就是 cost
- salt:每次 encode() 调用时随机生成,也编码进结果字符串(紧跟在 cost 后的22位字符)
encoder.encode() 返回的字符串格式为 $2b${cost}${salt}${hash},盐值和 cost 因子已经编码在哈希值自身中。matches() 时能从哈希字符串自动解析出这些参数,无需额外的数据库字段。
8. 常见误区
| # | 误区 | 正确做法 |
|---|---|---|
| 1 | 密码用 AES 加密存 | 加密可逆,应使用不可逆的哈希 |
| 2 | MD5/SHA + 盐就够了 | 通用哈希速度太快,必须用 bcrypt/Argon2 |
| 3 | 所有用户共用一个盐 | 每个用户独立随机盐,否则彩虹表仍可批量破解 |
| 4 | 盐需要保密 | 盐不需要保密,明文存储即可------它的作用是增加计算量,不是隐藏密钥 |
| 5 | cost 越大越好 | cost 需要在安全与用户体验间平衡,建议 ≤ 500 ms |
| 6 | 参数设置一次就够 | 硬件持续提升,应定期复查并升级参数 |
| 7 | 哈希算法可以自己设计 | 不要自己发明密码学算法,使用标准库 |
| 8 | HTTPS 就够安全了 | HTTPS 保护传输链路,不保护服务端存储------密码仍需要正确哈希 |
9. 总结
| 概念 | 说明 |
|---|---|
| 明文存储 | 禁止,一次泄露引发撞库连锁风险 |
| 加密存储 | 可逆,密码存储不应能还原出原文 |
| MD5/SHA 系列 | 追求速度,不适用密码哈希 |
| 加盐 | 每个用户独立随机盐,对抗彩虹表 |
| bcrypt | Web 应用首选,框架内置,开箱即用 |
| Argon2id | 现代推荐,PHC 冠军,抗 GPU/ASIC 更强 |
| cost / 参数调优 | 在安全与性能间权衡,定期复查 |