密码哈希算法 — bcrypt 与 Argon2 详解
一、为什么密码存储需要专用算法
1.1 普通哈希的致命缺陷
MD5/SHA-256 设计目标是快(文件校验、签名需要高速)。但用于密码存储时,快反而是致命缺点:
攻击者拿到数据库泄露的哈希值后: MD5 速度:~100亿次/秒(GPU) 6位纯数字密码有 100万种组合 暴力破解时间:100万 ÷ 100亿 = 0.0001秒 SHA-256 速度:~50亿次/秒(GPU) 8位小写字母密码有 2080亿种组合 暴力破解时间:2080亿 ÷ 50亿 = 41秒结论:普通哈希太快了,攻击者能在极短时间内遍历所有可能的密码。
1.2 密码哈希的核心设计理念
故意设计得很慢,让暴力破解的成本从"0.0001秒"变成"几百年"。
bcrypt 速度:~10次/秒(相同硬件) 6位纯数字密码:100万 ÷ 10 = 100000秒 ≈ 28小时 8位混合密码:几百年注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、bcrypt
2.1 什么是 bcrypt
基于 Blowfish 加密算法改造的密码哈希函数。1999年发布,至今仍是最广泛使用的密码哈希方案。
2.2 核心特性
| 特性 | 说明 |
|---|---|
| 自适应代价因子 | 可以调整计算轮数,硬件变快就增加轮数 |
| 内置盐值 | 自动生成随机盐,不需要手动管理 |
| 固定输出格式 | 输出包含算法标识+代价因子+盐+哈希值 |
| 最大输入限制 | 只处理前72字节(超长密码截断) |
2.3 输出格式解析
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy │ │ │ │ │ │ │ │ │ │ │ └─ 哈希值(31字符) │ │ │ │ └─ 盐值(22字符,Base64编码的16字节) │ │ │ └─ 代价因子(10表示2^10=1024轮) │ │ └─ 分隔符 │ └─ 算法版本(2a/2b/2y) └─ 标识符2.4 代价因子(Cost Factor)
代价因子决定计算复杂度,每增加1,计算时间翻倍:
| 代价因子 | 计算轮数 | 大约耗时 | 适用场景 |
|---|---|---|---|
| 4 | 16 | ~1ms | 仅测试用 |
| 10 | 1024 | ~100ms | 普通Web应用(推荐最低值) |
| 12 | 4096 | ~400ms | 安全要求较高 |
| 14 | 16384 | ~1.5s | 高安全场景 |
| 16 | 65536 | ~6s | 极高安全(注意用户体验) |
选择原则:让一次哈希耗时在 100ms-500ms 之间。太快不安全,太慢影响登录体验。
2.5 代码示例
importorg.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;publicclassBcryptExample{// 创建编码器,参数是代价因子(默认10)privatestaticfinalBCryptPasswordEncoderencoder=newBCryptPasswordEncoder(12);/** * 注册时:对密码进行哈希. */publicstaticStringhashPassword(StringrawPassword){Stringhashed=encoder.encode(rawPassword);// 输出示例:$2a$12$LJ3m4sMRpNeU8WpDU8h7tuQOBCKFPBRjPfOcg1vKLAvEjxV3mGCHareturnhashed;}/** * 登录时:验证密码. */publicstaticbooleanverifyPassword(StringrawPassword,StringstoredHash){returnencoder.matches(rawPassword,storedHash);}publicstaticvoidmain(String[]args){Stringpassword="myPassword123";// 注册 — 生成哈希存入数据库Stringhash1=hashPassword(password);Stringhash2=hashPassword(password);// 同一密码每次结果不同(盐不同)System.out.println("哈希1: "+hash1);System.out.println("哈希2: "+hash2);System.out.println("两次哈希相同? "+hash1.equals(hash2));// false!// 登录 — 验证System.out.println("验证正确密码: "+verifyPassword("myPassword123",hash1));// trueSystem.out.println("验证错误密码: "+verifyPassword("wrongPassword",hash1));// false}}2.6 为什么同一密码每次哈希结果不同
hashPassword("123456")→"$2a$12$ABC...xyz..."// 第一次hashPassword("123456")→"$2a$12$DEF...uvw..."// 第二次,完全不同!因为 bcrypt每次自动生成随机盐嵌入结果中。验证时从存储的哈希值中提取盐,重新计算后比对:
验证过程: 1. 从存储的 "$2a$12$ABC...xyz..." 中提取盐 "ABC..." 2. 用提取的盐 + 输入的密码 → 重新计算哈希 3. 计算结果与存储的哈希值比对 4. 一致 → 密码正确2.7 bcrypt 的工作原理(简化)
输入: 密码 + 盐 + 代价因子(n) 1. 用密码和盐初始化 Blowfish 密码的 key schedule 2. 循环 2^n 次: - 用密码重新加密 key schedule - 用盐重新加密 key schedule 3. 用最终的 key 加密固定明文 "OrpheanBeholderScryDoubt"(重复64次) 4. 输出加密结果作为哈希值 为什么慢? 第2步循环 2^n 次,每次都是完整的加密运算 为什么不能并行加速? 每次循环依赖上一次的结果(串行依赖)三、Argon2
3.1 什么是 Argon2
2015年 Password Hashing Competition 的冠军算法。设计目标:抵抗 GPU/FPGA/ASIC 等专用硬件的暴力破解。
3.2 bcrypt 的不足
bcrypt 只消耗 CPU 时间,而现代攻击者有大量 GPU:
CPU:串行计算,一个核心一次算一个 GPU:数千个核心并行,虽然单个慢但总量大 ASIC:专用芯片,专门设计来计算bcrypt bcrypt 只需 4KB 内存 → GPU/ASIC 可以轻松并行数千个实例3.3 Argon2 的解决方案:消耗内存
Argon2 设计思路: - 不仅消耗 CPU 时间,还消耗大量内存 - GPU 核心多但每个核心内存小 → 无法并行太多实例 - ASIC 造内存比造计算单元贵得多 → 提高攻击成本3.4 三个变体
| 变体 | 防御目标 | 适用场景 |
|---|---|---|
| Argon2d | 抵抗 GPU/ASIC(数据依赖访存) | 后端密码哈希、加密货币 |
| Argon2i | 抵抗侧信道攻击(数据无关访存) | 密钥派生 |
| Argon2id | 混合(先i后d) | 通用推荐,兼顾两者 |
3.5 核心参数
| 参数 | 含义 | 推荐值 |
|---|---|---|
| 内存代价(memory) | 使用多少 KB 内存 | 65536 KB(64MB) |
| 时间代价(iterations) | 计算轮数 | 3 |
| 并行度(parallelism) | 使用几个线程 | 4 |
| 输出长度 | 哈希值字节数 | 32字节 |
| 盐长度 | 随机盐字节数 | 16字节 |
3.6 代码示例
importorg.bouncycastle.crypto.generators.Argon2BytesGenerator;importorg.bouncycastle.crypto.params.Argon2Parameters;importjava.security.SecureRandom;importjava.util.Base64;publicclassArgon2Example{privatestaticfinalintMEMORY=65536;// 64MBprivatestaticfinalintITERATIONS=3;// 3轮privatestaticfinalintPARALLELISM=4;// 4线程privatestaticfinalintHASH_LENGTH=32;// 32字节输出privatestaticfinalintSALT_LENGTH=16;// 16字节盐/** * Argon2id 哈希密码. */publicstaticStringhashPassword(Stringpassword){// 生成随机盐byte[]salt=newbyte[SALT_LENGTH];newSecureRandom().nextBytes(salt);// 配置参数Argon2Parametersparams=newArgon2Parameters.Builder(Argon2Parameters.ARGON2_id).withMemoryAsKB(MEMORY).withIterations(ITERATIONS).withParallelism(PARALLELISM).withSalt(salt).build();// 计算哈希Argon2BytesGeneratorgenerator=newArgon2BytesGenerator();generator.init(params);byte[]hash=newbyte[HASH_LENGTH];generator.generateBytes(password.toCharArray(),hash);// 编码为存储格式:$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>StringsaltBase64=Base64.getEncoder().withoutPadding().encodeToString(salt);StringhashBase64=Base64.getEncoder().withoutPadding().encodeToString(hash);returnString.format("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",MEMORY,ITERATIONS,PARALLELISM,saltBase64,hashBase64);}/** * 验证密码. */publicstaticbooleanverifyPassword(Stringpassword,StringstoredHash){// 解析存储的哈希值中的参数和盐String[]parts=storedHash.split("\\$");// parts[3] = "m=65536,t=3,p=4"// parts[4] = salt (Base64)// parts[5] = hash (Base64)String[]paramParts=parts[3].split(",");intmemory=Integer.parseInt(paramParts[0].substring(2));intiterations=Integer.parseInt(paramParts[1].substring(2));intparallelism=Integer.parseInt(paramParts[2].substring(2));byte[]salt=Base64.getDecoder().decode(parts[4]);byte[]expectedHash=Base64.getDecoder().decode(parts[5]);// 用相同参数重新计算Argon2Parametersparams=newArgon2Parameters.Builder(Argon2Parameters.ARGON2_id).withMemoryAsKB(memory).withIterations(iterations).withParallelism(parallelism).withSalt(salt).build();Argon2BytesGeneratorgenerator=newArgon2BytesGenerator();generator.init(params);byte[]computedHash=newbyte[expectedHash.length];generator.generateBytes(password.toCharArray(),computedHash);// 时间安全比较(防止时序攻击)returnconstantTimeEquals(expectedHash,computedHash);}/** * 常量时间比较(防止时序攻击). */privatestaticbooleanconstantTimeEquals(byte[]a,byte[]b){if(a.length!=b.length)returnfalse;intresult=0;for(inti=0;i<a.length;i++){result|=a[i]^b[i];// 异或:相同为0,不同为非0}returnresult==0;// 全部相同才返回true}}3.7 Spring Security 中使用 Argon2
importorg.springframework.security.crypto.argon2.Argon2PasswordEncoder;// Spring Security 5.3+ 内置支持Argon2PasswordEncoderencoder=newArgon2PasswordEncoder(16,// 盐长度32,// 哈希长度4,// 并行度65536,// 内存(KB)3// 迭代次数);Stringhash=encoder.encode("myPassword123");booleanvalid=encoder.matches("myPassword123",hash);3.8 Argon2 输出格式
$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$WQ1kF3+8L7MvA9vZ8pXtGw │ │ │ │ │ │ │ │ │ └─ 哈希值(Base64无填充) │ │ │ └─ 盐值(Base64无填充) │ │ └─ 参数:m=内存KB, t=迭代次数, p=并行度 │ └─ 版本号(19 = 0x13) └─ 算法标识四、bcrypt vs Argon2 对比
| 维度 | bcrypt | Argon2id |
|---|---|---|
| 发布年份 | 1999 | 2015 |
| 防 CPU 暴力 | ✅ 可调轮数 | ✅ 可调迭代次数 |
| 防 GPU 并行 | ⚠️ 内存需求小(4KB),GPU可大量并行 | ✅ 内存需求大,GPU并行受限 |
| 防 ASIC | ⚠️ 已有专用芯片 | ✅ 大内存让ASIC成本极高 |
| 最大输入 | 72字节(截断) | 无限制 |
| 参数灵活性 | 只有代价因子一个参数 | 内存+时间+并行度三个参数 |
| 生态成熟度 | 极成熟,所有语言/框架支持 | 较新,主流框架已支持 |
| 推荐 | 仍然安全可用 | 新项目首选 |
五、时序攻击与常量时间比较
5.1 什么是时序攻击
普通字符串比较遇到第一个不同字符就返回 false:
// 危险的比较方式publicbooleanunsafeEquals(Stringa,Stringb){if(a.length()!=b.length())returnfalse;for(inti=0;i<a.length();i++){if(a.charAt(i)!=b.charAt(i))returnfalse;// 第一个不同就返回}returntrue;}攻击者可以测量响应时间:
输入 "Axxxxxxx" → 1字符匹配后返回 → 耗时 1μs 输入 "Cxxxxxxx" → 0字符匹配后返回 → 耗时 0.8μs → 第一个字符更可能是 A 或 B(耗时更长说明匹配更多) 逐位猜测,就能推断出正确的哈希值5.2 常量时间比较
// 安全的比较方式:不管哪个位置不同,总是比较完所有字节publicstaticbooleanconstantTimeEquals(byte[]a,byte[]b){if(a.length!=b.length)returnfalse;intresult=0;for(inti=0;i<a.length;i++){result|=a[i]^b[i];// 不提前返回,始终遍历完}returnresult==0;}bcrypt 和 Argon2 的验证实现内部都使用常量时间比较。
六、实际应用中的密码存储方案
6.1 Spring Boot 项目推荐配置
importorg.springframework.context.annotation.Bean;importorg.springframework.security.crypto.factory.PasswordEncoderFactories;importorg.springframework.security.crypto.password.PasswordEncoder;@ConfigurationpublicclassSecurityConfig{@BeanpublicPasswordEncoderpasswordEncoder(){// 使用委托模式,默认bcrypt,支持多种算法// 输出格式:{bcrypt}$2a$10$...// 旧数据可以标记为 {MD5}xxx 或 {sha256}xxx 兼容迁移returnPasswordEncoderFactories.createDelegatingPasswordEncoder();}}@ServicepublicclassUserService{@ResourceprivatePasswordEncoderpasswordEncoder;/** * 注册. */publicvoidregister(Stringusername,StringrawPassword){StringencodedPassword=passwordEncoder.encode(rawPassword);// 存入数据库:{bcrypt}$2a$10$N9qo8uLO...userRepository.save(newUser(username,encodedPassword));}/** * 登录验证. */publicbooleanlogin(Stringusername,StringrawPassword){Useruser=userRepository.findByUsername(username);if(user==null)returnfalse;returnpasswordEncoder.matches(rawPassword,user.getPassword());}}6.2 从 MD5 迁移到 bcrypt 的策略
旧系统用 MD5 存密码,如何平滑迁移?
/** * 双重验证 + 渐进迁移. */publicbooleanloginAndMigrate(Stringusername,StringrawPassword){Useruser=userRepository.findByUsername(username);StringstoredPassword=user.getPassword();if(storedPassword.startsWith("{bcrypt}")){// 新格式:直接bcrypt验证returnpasswordEncoder.matches(rawPassword,storedPassword);}else{// 旧格式:MD5验证Stringmd5Hash=DigestUtils.md5Hex(rawPassword);if(md5Hash.equals(storedPassword)){// 验证通过后,用bcrypt重新哈希并更新StringnewHash=passwordEncoder.encode(rawPassword);user.setPassword(newHash);userRepository.save(user);returntrue;}returnfalse;}}七、选型决策
新项目密码存储? │ ├── Java/Spring Boot 项目 │ → BCryptPasswordEncoder(成熟稳定) │ → 或 Argon2PasswordEncoder(更安全) │ ├── 安全要求极高(金融/支付) │ → Argon2id(m=64MB, t=3, p=4) │ ├── 资源受限(嵌入式/低配服务器) │ → bcrypt(cost=10) │ └── 需要兼容旧系统 → DelegatingPasswordEncoder(支持多算法并存)一句话总结:bcrypt 和 Argon2 的核心价值就是"故意让计算变慢+消耗资源",从而让暴力破解变得不可行。bcrypt 是久经考验的选择,Argon2 是面向未来硬件威胁的升级方案。