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 通过以下方式解决这个问题:

  1. 故意设计得慢:每次哈希计算都需要大量时间
  2. 可配置:可以通过成本因子调整计算速度
  3. 盐值集成:每个密码自动获得唯一的盐值

核心特性

特性 描述
算法 基于 Blowfish 密码(Eksblowfish)
编码结果 60 个字符:版本、成本、16 字节盐值和 23 字节校验部分的编码
盐值 128 位,自动生成
成本因子 可配置(4-31),指数级工作量增加
字符串格式 $2a$$2b$$2y$ 前缀

Bcrypt工作原理

Bcrypt 的安全性来自其独特的密码哈希方法。以下是处理流程:

flowchart TD A[密码输入] --> B[生成随机盐值] B --> C["组合密码和盐值"] C --> D[密钥设置阶段] D --> E["昂贵的密钥调度 (2^cost 次迭代)"] E --> F[加密魔术值] F --> G[最终哈希输出] style A fill:#e1f5fe,stroke:#01579b style B fill:#fff3e0,stroke:#e65100 style E fill:#fce4ec,stroke:#c2185b style G fill:#e8f5e9,stroke:#2e7d32

分步过程

  1. 盐值生成:生成密码学安全的 128 位随机盐值
  2. 密钥设置:使用密码和盐值初始化 Eksblowfish 密码
  3. 昂贵的密钥调度:密钥调度重复 2^cost 次
  4. 加密:魔术值("OrpheanBeholderScryDoubt")被加密 64 次
  5. 输出:盐值和结果哈希组合成最终字符串

为什么相同密码产生不同哈希

一个常见问题是:"为什么对同一个密码哈希两次会得到不同的结果?"

这是因为 Bcrypt 为每次哈希操作生成新的随机盐值。盐值随后被嵌入到输出字符串中,因此验证时可以提取它并重现相同的哈希。

code
密码: "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。

选择合适的成本因子

flowchart LR A[开始] --> B{"哈希时间目标?"} B -->|在实测预算内| C["较低因子"] B -->|达到实测目标| D["选定因子"] B -->|超过预算| E["降低或重构"] C --> F["适合高流量"] D --> G["推荐平衡"] E --> H["最高安全性"] style D fill:#e8f5e9,stroke:#2e7d32 style G fill:#e8f5e9,stroke:#2e7d32

建议:

  • 开发/测试:仅在隔离测试配置中使用较低值。
  • 生产环境:选择满足可用性和登录延迟预算的最高实测值。
  • 更高保障:提高因子前测量 CPU 饱和、排队和拒绝服务暴露面。

随时间升级成本因子

随着硬件改进,你应该增加成本因子。以下是一个策略:

javascript
// 在登录时检查哈希是否需要升级
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:

code
$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

何时使用哪个

flowchart TD A[选择算法] --> B{新项目?} B -->|是| C{内存可用?} B -->|否| D["保持当前算法"] C -->|是| E[Argon2id] C -->|否| F[Bcrypt] D --> G{"使用MD5或SHA?"} G -->|是| H["迁移到Bcrypt或Argon2"] G -->|否| I["如需要增加成本因子"] style E fill:#e8f5e9,stroke:#2e7d32 style F fill:#e8f5e9,stroke:#2e7d32

总结:

  • 新项目:考虑 Argon2id(如果有库支持)
  • 现有项目:Bcrypt 仍然很优秀
  • 遗留系统:从 MD5/SHA 迁移到 Bcrypt
  • 资源受限:Bcrypt(内存需求较低)

代码示例

Node.js (bcryptjs)

javascript
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

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)

java
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

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
}

安全最佳实践

应该做的 ✅

  1. 在类生产硬件和预期并发下校准成本因子
  2. 存储完整的哈希字符串(包含盐值和版本)
  3. 使用常量时间比较(内置于 bcrypt 库中)
  4. 随着硬件改进升级成本因子
  5. 传输密码时使用 HTTPS
  6. 在登录端点实施速率限制

不应该做的 ❌

  1. 不要未经测量就照搬成本因子或延迟目标
  2. 不要单独存储盐值(它在哈希中)
  3. 不要静默截断密码
  4. 不要将 bcrypt 用于非密码数据(使用 SHA-256)
  5. 不要以明文记录密码或哈希
  6. 不要自己实现 bcrypt(使用成熟的库)

密码长度考虑

Bcrypt 实现通常只处理前 72 个字节。多字节编码和 NUL 字节可能带来边界差异,应定义字节长度策略,对超长密码拒绝,或明确记录并版本化更长密码方案:

javascript
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:

javascript
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 存储密码。

一手来源

总结

使用仍在维护的实现、经过明确测试的输入策略和经校准的成本时,Bcrypt 仍是实用的密码哈希兼容方案。但它不应成为所有新系统的首选:当前 OWASP 指导建议在条件允许时优先选择 Argon2id。

要记住的关键点:

  • 使用根据部署硬件和并发校准的成本因子
  • 哈希字符串包含验证所需的一切
  • 随着硬件改进升级成本因子
  • 对于有内存密集需求的新项目考虑 Argon2

不要把真实密码提交到在线服务。验证实现时应使用本地测试样本或经过批准的隔离环境。