Web服务密码存储安全详解:从哈希到密钥派生的演进

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,其中已经内嵌了盐值。

实际存储方式有两种:

  1. 独立盐字段 (适用于手动 SHA-256 + 盐的方式)------ user 表中有 saltpassword_hash 两列
  2. 嵌入式盐 (推荐,适用于 bcrypt / Argon2)------ 哈希字符串自身编码了所有信息,只需一个 password_hash 字段。以 bcrypt 为例:
graph TB classDef algo fill:#a5d8ff,stroke:#1e40af,stroke-width:2px classDef cost fill:#fff3bf,stroke:#d97706,stroke-width:2px classDef salt fill:#b2f2bb,stroke:#15803d,stroke-width:2px classDef hval fill:#d0bfff,stroke:#7c3aed,stroke-width:2px full[&#34;$2b$10$SaLtSaLtSaLtSaLtSaLtSu.HashValueHere&#34;] a[&#34;算法版本: $2b&#34;]:::algo c[&#34;cost 因子: 10<br/>(2^10 = 1024 轮,<br/>控制计算耗时)&#34;]:::cost s[&#34;随机盐值: 22 字符 = 128 bit&#34;]:::salt v[&#34;实际哈希值: 31 字符&#34;]:::hval full --> a full --> c full --> s full --> v

现代密码库(bcrypt、argon2)默认使用这种格式,开发者无需手动管理盐值。

5. 密码专用哈希函数(密钥派生函数)

通用哈希函数追求"快",密码专用哈希函数追求"慢且难并行"------让攻击者的每次尝试都付出高昂代价。

这类函数统称为慢哈希函数 (Slow Hash Function)或密码哈希函数 (Password Hashing Function)。其中 scrypt 和 Argon2 还属于 KDF(Key Derivation Function,密钥派生函数)------它们可以从密码中派生出指定长度的密钥,用于加密等场景。整个演进路径就是:

graph LR classDef old fill:#ffc9c9,stroke:#c92a2a,stroke-width:2px classDef bc fill:#a5d8ff,stroke:#1e40af,stroke-width:2px classDef sc fill:#b2f2bb,stroke:#15803d,stroke-width:2px classDef ar fill:#d0bfff,stroke:#5f3dc4,stroke-width:2px A[&#34;MD5 / SHA 系列<br/>(通用哈希)&#34;]:::old B[&#34;bcrypt<br/>(密码专用哈希)&#34;]:::bc C[&#34;scrypt<br/>(内存硬 KDF)&#34;]:::sc D[&#34;Argon2id<br/>(综合最优 KDF)&#34;]:::ar A -->|快, 不适用密码| B B -->|固定内存 ~4 KB| C C -->|参数复杂| D

对比普通哈希、慢哈希和 KDF 在密码存储中的定位:

graph LR classDef hash fill:#ffc9c9,stroke:#c92a2a,stroke-width:2px classDef slow fill:#fff3bf,stroke:#d97706,stroke-width:2px classDef kdf fill:#b2f2bb,stroke:#15803d,stroke-width:2px classDef neutral fill:#e7f5ff,stroke:#1971c2,stroke-width:2px P[&#34;用户密码&#34;]:::neutral subgraph type1 [&#34;通用哈希&#34;] direction LR A[&#34;MD5 / SHA&#34;]:::hash A1[&#34;固定哈希值&#34;]:::hash A2[&#34;存入数据库&#34;]:::hash A --> A1 --> A2 end subgraph type2 [&#34;慢哈希(密码专用哈希)&#34;] direction LR B[&#34;bcrypt&#34;]:::slow B1[&#34;慢哈希值<br/>($2b$12$...)&#34;]:::slow B2[&#34;存入数据库&#34;]:::slow B --> B1 --> B2 end subgraph type3 [&#34;KDF(密钥派生函数)&#34;] direction LR C[&#34;scrypt / Argon2&#34;]:::kdf C1[&#34;派生密钥/哈希值&#34;]:::kdf C2[&#34;存入数据库&#34;]:::kdf C --> C1 --> C2 end P --> type1 P --> type2 P --> type3
特性 普通哈希(SHA-256) KDF(Argon2 / scrypt)
输出长度 固定(如 256 bit) 可配置
速度 极快(数十亿次/秒) 慢(可调节)
内存占用 极小 可配置(抗 ASIC)
设计用途 数据完整性校验 密码存储 / 派生密钥

5.1 bcrypt

1999 年由 Niels Provos 和 David Mazières 基于 Blowfish 密码算法设计,至今仍是最广泛使用的密码哈希方案。

核心特点:

  • 内嵌盐值(16 字节),无需开发者手动管理
  • 可配置 cost 因子( 242^4 24 ~ 2312^{31} 231),默认通常为 2102^{10} 210 或 2122^{12} 212
  • 抗 GPU/ASIC 加速 ------ 算法内建内存访问模式不利于并行硬件
  • 输出固定 60 字符,格式 $2b$10$...

** 2avs2a vs 2avs2b vs 2y∗∗bcrypt有多个版本标识。'2y** bcrypt 有多个版本标识。` 2y∗∗bcrypt有多个版本标识。'2a 是原始版, 2b'修复了一个处理0xFF字符的bug,'2b` 修复了一个处理 0xFF 字符的 bug,` 2b'修复了一个处理0xFF字符的bug,'2y是某些第三方库的修正标记。新代码应使用$2b`。

局限: 抗 GPU 能力不如 scrypt 和 Argon2,内存占用固定(约 4 KB)。

5.2 scrypt

2009 年由 Colin Percival 设计,引入内存硬性(Memory-hard)概念------不仅要求计算量,还要求大内存。

核心特点:

  • 参数:N(CPU/内存消耗)、r(块大小)、p(并行度)
  • 内存消耗 = 128×N×r128 \times N \times r 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 MB
  • iterations(迭代次数,也称 time cost)------ 如 3
  • parallelism(并行线程数)------ 如 4
  • salt(随机盐值)------ 至少 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 能力
密码管理器、加密钱包 Argon2idscrypt 需要极强抗暴力破解,可接受更大计算开销
嵌入式 / 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)

整个注册和登录流程可以用时序图直观理解:

sequenceDiagram actor U as 用户 participant App as 客户端 participant Ctrl as AuthController participant DB as 数据库 rect rgb(200, 230, 255) Note over U,DB: 注册流程 U->>App: 输入用户名+密码 App->>Ctrl: POST /register {username, password} Ctrl->>Ctrl: encoder.encode(password) Note over Ctrl: 内部生成随机盐 + cost=12<br/>返回 $2b$12$salt$hash Ctrl->>DB: INSERT password_hash Note over DB: users DB-->>Ctrl: OK Ctrl-->>App: 注册成功 App-->>U: 提示注册完成 end rect rgb(230, 255, 230) Note over U,DB: 登录流程 U->>App: 输入用户名+密码 App->>Ctrl: POST /login {username, password} Ctrl->>DB: SELECT password_hash Note over DB: users DB-->>Ctrl: $2b$12$salt$hash Ctrl->>Ctrl: encoder.matches(password, hash) Note over Ctrl: 从 hash 中解析出 salt 和 cost<br/>用相同参数计算并比对 Ctrl-->>App: 登录成功 App-->>U: 进入系统 end

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_hashencoder.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):已编码进结果字符串里,格式为 2a2a 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 / 参数调优 在安全与性能间权衡,定期复查
相关推荐
如果超人不会飞1 小时前
TinyRobot Sender打造强大的AI聊天输入体验
前端·vue.js
weixin_307779131 小时前
从工具到协作者:AI在后端研发中的流程重构与组织赋能
人工智能·后端·python·算法·自动化
爱吃生蚝的于勒1 小时前
QT开发第三章——常用控件
linux·服务器·开发语言·前端·javascript·c++·qt
fliter1 小时前
Rust 如何用 Josh 管理跨仓库代码共享
后端
xuankuxiaoyao1 小时前
Axios-图书列表案例
开发语言·前端·javascript
影寂ldy1 小时前
C# 多播委托
前端·javascript·c#
dy17171 小时前
Vue3 多文件上传
前端·javascript·vue.js
文阿花2 小时前
Echarts实现3D饼状图
前端·javascript·echarts·饼状图
li-xun2 小时前
我给自己的 Django 博客做了一个在线工具箱:从图片压缩到正则测试,尽量都在浏览器本地处理
后端·python·django