Bcrypt 是一种密码哈希函数,目标是让离线密码猜测比 MD5、SHA-256 等快速摘要更昂贵。它将盐值写入编码结果,并提供可调工作因子;但它并不能保证账户绝对安全,密码质量、速率限制、凭据泄露响应、传输安全和部署校准同样重要。
目录
核心要点
- 内置盐值:Bcrypt 自动为每个密码生成唯一的 128 位盐值,无需手动管理盐值。
- 自适应成本因子:工作因子(轮数)可随时间增加,以跟上硬件性能的提升。
- 故意设计得慢:Bcrypt 故意计算密集,使暴力破解攻击变得不切实际。
- 哈希结构:Bcrypt 哈希包含算法版本、成本因子、盐值和哈希值,全部在一个字符串中。
- 成本因子:应在目标硬件和预期并发下测量,不要照搬通用毫秒目标。
- 算法选择:Bcrypt 是兼容性选项;新系统应在部署能承受内存、时间和并行度参数时评估 Argon2id。
什么是Bcrypt?
Bcrypt 是由 Niels Provos 和 David Mazières 于 1999 年设计的密码哈希函数,基于 Blowfish 密码算法。"bcrypt"这个名字来源于"Blowfish crypt",反映了其密码学基础。
为什么要创建Bcrypt
传统的哈希函数如 MD5 和 SHA-1 被设计得很快,这对数据完整性检查很好,但对密码存储来说很糟糕。快速哈希意味着攻击者每秒可以尝试数十亿次密码猜测。
Bcrypt 通过以下方式解决这个问题:
- 故意设计得慢:每次哈希计算都需要大量时间
- 可配置:可以通过成本因子调整计算速度
- 盐值集成:每个密码自动获得唯一的盐值
核心特性
| 特性 | 描述 |
|---|---|
| 算法 | 基于 Blowfish 密码(Eksblowfish) |
| 编码结果 | 60 个字符:版本、成本、16 字节盐值和 23 字节校验部分的编码 |
| 盐值 | 128 位,自动生成 |
| 成本因子 | 可配置(4-31),指数级工作量增加 |
| 字符串格式 | $2a$、$2b$ 或 $2y$ 前缀 |
Bcrypt工作原理
Bcrypt 的安全性来自其独特的密码哈希方法。以下是处理流程:
分步过程
- 盐值生成:生成密码学安全的 128 位随机盐值
- 密钥设置:使用密码和盐值初始化 Eksblowfish 密码
- 昂贵的密钥调度:密钥调度重复 2^cost 次
- 加密:魔术值("OrpheanBeholderScryDoubt")被加密 64 次
- 输出:盐值和结果哈希组合成最终字符串
为什么相同密码产生不同哈希
一个常见问题是:"为什么对同一个密码哈希两次会得到不同的结果?"
这是因为 Bcrypt 为每次哈希操作生成新的随机盐值。盐值随后被嵌入到输出字符串中,因此验证时可以提取它并重现相同的哈希。
密码: "mypassword"
第一次哈希: $2b$10$N9qo8uLOickgx2ZMRZoMy.MqrqQb9lYz6H8Kj7OvBOyj5uYjiPWmu
第二次哈希: $2b$10$ZGdlbGVwaGFudHNhcmVjb.7xJ8L9KjQvMnOpRsTuVwXyZaBcDeFgH
两个都是 "mypassword" 的有效哈希 - 不同的盐值,相同的密码!
理解成本因子
成本因子(也称为"轮数"或"工作因子")决定了哈希过程的计算密集程度。它以 2 的幂次表示。
成本因子影响
| 成本因子 | 相对密钥调度工作量 |
|---|---|
| 4 | 2⁴ |
| 8 | 2⁸ |
| 10 | 2¹⁰ |
| 12 | 2¹² |
| 14 | 2¹⁴ |
| 16 | 2¹⁶ |
这些是工作因子关系,不是延迟承诺。应在生产硬件和预期登录并发下测量 p50/p95。
选择合适的成本因子
建议:
- 开发/测试:仅在隔离测试配置中使用较低值。
- 生产环境:选择满足可用性和登录延迟预算的最高实测值。
- 更高保障:提高因子前测量 CPU 饱和、排队和拒绝服务暴露面。
随时间升级成本因子
随着硬件改进,你应该增加成本因子。以下是一个策略:
// 在登录时检查哈希是否需要升级
async function loginAndUpgrade(password, storedHash) {
const isValid = await bcrypt.compare(password, storedHash);
if (isValid) {
const currentCost = parseInt(storedHash.split('$')[2]);
const targetCost = 12;
if (currentCost < targetCost) {
// 使用更高的成本因子重新哈希
const newHash = await bcrypt.hash(password, targetCost);
await updateUserHash(newHash);
}
}
return isValid;
}
Bcrypt哈希结构解析
Bcrypt 哈希字符串包含兼容校验器所需的版本、成本、盐值和校验部分。盐值与校验部分使用 Bcrypt 自定义 Base64 字母表,并非 RFC 4648 标准 Base64:
$2b$12$N9qo8uLOickgx2ZMRZoMyeKj7OvBOyj5uYjiPWmuabcdefghijk
│ │ │ │ │
│ │ │ │ └── 校验部分编码(31个字符)
│ │ │ └── 盐值(22个字符)
│ │ └── 成本因子(2位数字)
│ └── 算法版本
└── 前缀标记
算法版本
| 版本 | 描述 |
|---|---|
$2$ |
原始规范(已过时) |
$2a$ |
修复了bug,最常见 |
$2b$ |
修复了无符号字符bug(2014年) |
$2y$ |
PHP 生态变体,必须验证所选库的互操作性 |
解码真实哈希
让我们分解这个哈希:$2b$10$vI8aWBnW3fID.ZQ4/zo1G.q1lRps.9cGLcZEiGDMVr5yUP1KUOYTa
| 组件 | 值 | 含义 |
|---|---|---|
| 算法 | $2b$ |
Bcrypt 版本 2b |
| 成本 | 10 |
2^10 = 1,024 次迭代 |
| 盐值 | vI8aWBnW3fID.ZQ4/zo1G. |
22 字符 Bcrypt-Base64 盐值编码 |
| 校验部分 | q1lRps.9cGLcZEiGDMVr5yUP1KUOYTa |
31 字符 Bcrypt-Base64 校验部分编码 |
Bcrypt与其他算法对比
对比表
| 特性 | Bcrypt | Argon2id | Scrypt | PBKDF2 |
|---|---|---|---|---|
| 年份 | 1999 | 2015 | 2009 | 2000 |
| 内存密集 | 否 | 是 | 是 | 否 |
| 主要可调成本 | CPU | 内存、时间、并行度 | 内存、CPU | 迭代次数 / PRF |
| OWASP 密码存储定位 | 旧系统 | 首选 | Argon2id 不可用时 | 面向 FIPS 的部署 |
| 重要边界 | 常见实现有 72 字节输入限制 | 必须按安全并发量配置内存 | 参数需要按部署校准 | 需要较高迭代次数和符合要求的 PRF |
何时使用哪个
总结:
- 新项目:考虑 Argon2id(如果有库支持)
- 现有项目:Bcrypt 仍然很优秀
- 遗留系统:从 MD5/SHA 迁移到 Bcrypt
- 资源受限:Bcrypt(内存需求较低)
代码示例
Node.js (bcryptjs)
const bcrypt = require('bcryptjs');
// 生成哈希
async function hashPassword(password) {
const costFactor = 12; // 仅为示例,应根据部署环境校准
const hash = await bcrypt.hash(password, costFactor);
return hash;
}
// 验证密码
async function verifyPassword(password, hash) {
const isMatch = await bcrypt.compare(password, hash);
return isMatch;
}
// 使用示例
async function main() {
const password = 'test-only-password-from-a-fixture';
// 哈希密码
const hash = await hashPassword(password);
// 将哈希存入受保护的凭据存储,不要记录日志。
// 验证正确密码
const isValid = await verifyPassword(password, hash);
console.log('有效:', isValid); // true
// 验证错误密码
const isInvalid = await verifyPassword('wrongPassword', hash);
console.log('无效:', isInvalid); // false
}
main();
Python
import bcrypt
def hash_password(password: str) -> bytes:
"""使用bcrypt哈希密码"""
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed
def verify_password(password: str, hashed: bytes) -> bool:
"""验证密码与哈希是否匹配"""
return bcrypt.checkpw(password.encode('utf-8'), hashed)
# 使用示例
password = "test-only-password-from-a-fixture"
# 哈希
hashed = hash_password(password)
# 将哈希存入受保护的凭据存储,不要记录日志。
# 验证
is_valid = verify_password(password, hashed)
print(f"有效: {is_valid}") # True
is_invalid = verify_password("wrongPassword", hashed)
print(f"无效: {is_invalid}") # False
Java (Spring Security)
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class BcryptExample {
public static void main(String[] args) {
// 创建强度(成本因子)为12的编码器
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
String password = "test-only-password-from-a-fixture";
// 哈希密码
String hash = encoder.encode(password);
// 将哈希存入受保护的凭据存储,不要记录日志。
// 验证密码
boolean isValid = encoder.matches(password, hash);
System.out.println("有效: " + isValid); // true
boolean isInvalid = encoder.matches("wrongPassword", hash);
System.out.println("无效: " + isInvalid); // false
}
}
Go
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12)
return string(bytes), err
}
func verifyPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
func main() {
password := "test-only-password-from-a-fixture"
// 哈希
hash, err := hashPassword(password)
if err != nil {
panic(err) // 生产代码应返回结构化错误
}
// 将哈希存入受保护的凭据存储,不要记录日志。
// 验证
isValid := verifyPassword(password, hash)
fmt.Println("有效:", isValid) // true
isInvalid := verifyPassword("wrongPassword", hash)
fmt.Println("无效:", isInvalid) // false
}
安全最佳实践
应该做的 ✅
- 在类生产硬件和预期并发下校准成本因子
- 存储完整的哈希字符串(包含盐值和版本)
- 使用常量时间比较(内置于 bcrypt 库中)
- 随着硬件改进升级成本因子
- 传输密码时使用 HTTPS
- 在登录端点实施速率限制
不应该做的 ❌
- 不要未经测量就照搬成本因子或延迟目标
- 不要单独存储盐值(它在哈希中)
- 不要静默截断密码
- 不要将 bcrypt 用于非密码数据(使用 SHA-256)
- 不要以明文记录密码或哈希
- 不要自己实现 bcrypt(使用成熟的库)
密码长度考虑
Bcrypt 实现通常只处理前 72 个字节。多字节编码和 NUL 字节可能带来边界差异,应定义字节长度策略,对超长密码拒绝,或明确记录并版本化更长密码方案:
const bcrypt = require('bcryptjs');
async function hashPasswordWithLengthPolicy(password) {
if (Buffer.byteLength(password, 'utf8') > 72) {
throw new Error('password_too_long_for_bcrypt_policy');
}
const costFactor = 12; // 应根据部署环境校准
return bcrypt.hash(password, costFactor);
}
如果系统确实要预哈希,必须采用有文档、域分离、带版本的构造并准备迁移方案,不能把它作为隐式兼容修复。
Bcrypt 生成与验证工具只应用测试样本检查编码哈希或演练 compare 操作。生产认证必须在受控应用环境中运行,采用维护中的服务端库、经校准的成本、速率限制和上述凭据处理控制。哈希术语进一步区分密码哈希与通用摘要。
常见问题
Q1: 为什么相同密码产生不同的哈希?
Bcrypt 为每次哈希操作生成唯一的随机盐值。这个盐值被嵌入到输出字符串中。验证时,盐值被提取出来用于重现哈希。这种设计防止了彩虹表攻击。
Q2: 应该使用什么成本因子?
不存在通用值。应在类生产硬件上测量所选库,在预期并发下观察 p50/p95,并选择满足可用性和登录延迟预算的最高因子。
Q3: Bcrypt 在 2026 年还安全吗?
Bcrypt 在使用维护中的库、经过校准的工作因子、速率限制和泄露响应控制时,仍是可用的兼容性方案。新系统应在部署适配内存、时间和并行度参数时评估 Argon2id,并核对当前规范与库支持,不能把任何算法当作绝对保证。
Q4: 可以将 Bcrypt 用于 API 密钥或令牌吗?
不可以,Bcrypt 是为密码验证设计的,不是通用哈希。对于 API 密钥或令牌,使用 SHA-256 或 HMAC-SHA256。Bcrypt 的慢速会为高频操作带来性能问题。
Q5: 如何从 MD5 迁移到 Bcrypt?
用户登录时应使用隔离的旧方案验证器,校验成功后原子替换旧凭据;不要再写入新的 MD5:
async function migrateHash(password, user, costFactor) {
const valid = await verifyLegacyMd5ForUser(password, user.legacyHash);
if (!valid) {
return false;
}
const newHash = await bcrypt.hash(password, costFactor);
await replaceCredentialInTransaction(user.id, newHash, {
removeLegacyHash: true
});
return true;
}
旧方案验证器必须受到速率限制,并在迁移窗口结束后移除;verifyLegacyMd5ForUser 只是应用边界,不是建议继续使用 MD5 存储密码。
一手来源
- OWASP Password Storage Cheat Sheet — Argon2id 优先级、Bcrypt 旧系统指导、工作因子和 72 字节边界
- A Future-Adaptable Password Scheme — Niels Provos 与 David Mazières 的 Bcrypt 原始设计
- NIST SP 800-63B:Authentication and Authenticator Management — 密码验证器、传输、限流和密码阻止列表要求
总结
使用仍在维护的实现、经过明确测试的输入策略和经校准的成本时,Bcrypt 仍是实用的密码哈希兼容方案。但它不应成为所有新系统的首选:当前 OWASP 指导建议在条件允许时优先选择 Argon2id。
要记住的关键点:
- 使用根据部署硬件和并发校准的成本因子
- 哈希字符串包含验证所需的一切
- 随着硬件改进升级成本因子
- 对于有内存密集需求的新项目考虑 Argon2
不要把真实密码提交到在线服务。验证实现时应使用本地测试样本或经过批准的隔离环境。