← 返回博客首页

密码哈希指南:bcrypt、argon2 与 scrypt 对比

为什么密码必须哈希?

用户注册时提交的密码是明文。如果数据库被拖库(SQL 注入、备份泄漏、内部人员),明文存储意味着所有用户密码瞬间暴露。由于大量用户跨站复用密码,一次泄漏会引发连锁灾难。

密码哈希的作用:即使数据库泄漏,攻击者也无法还原出原始密码,只能在有限时间内尝试暴力破解。哈希是密码安全的最后一道防线

哈希 vs 加密

特性 哈希(Hash) 加密(Encrypt)
方向 单向,不可逆 双向,可解密
用途 密码存储 传输保密、文件加密
密钥 不需要 需要密钥
示例 bcrypt, argon2 AES, RSA

密码绝不能加密存储——加密意味着密钥泄漏后全盘暴露,且密钥管理比哈希更复杂。哈希才是正解。

为什么不能用 MD5 / SHA-256?

通用哈希函数(MD5、SHA-1、SHA-256)设计目标是——为了文件校验、数据索引的高吞吐。但"快"对密码存储是致命的:

  • 现代 GPU 每秒可计算数十亿次 SHA-256
  • 攻击者用普通显卡几小时就能穷举 8 位以内所有密码
  • MD5 / SHA-1 已被证明存在碰撞漏洞,绝对禁用

密码哈希需要的是故意慢消耗资源的算法,让每次哈希都代价高昂,从而把暴力破解的成本拉到不可接受的程度。

核心概念

盐值(Salt)

盐是一段随机字符串,与密码拼接后再哈希:

hash = bcrypt(password + salt, cost)

盐的作用:让相同密码产生不同哈希。

场景 无盐 有盐
两个用户密码都是 123456 哈希相同,可批量破解 哈希不同,必须逐个破解
彩虹表攻击(预计算的哈希表) 直接命中 失效,需重新计算
数据库泄漏 一次破解波及所有相同密码 每个用户独立

盐的要求

  • 每个用户唯一(用用户 ID 不够,应用 CSPRNG 生成的随机字节)
  • 足够长(至少 16 字节 / 128 位)
  • 不需要保密(与哈希一起存储即可)

现代密码哈希库(bcrypt、argon2)会自动生成盐并嵌入输出中,无需手动管理。

工作因子(Work Factor / Cost)

工作因子控制哈希计算的迭代次数或资源消耗:

  • bcrypt 的 cost 参数:2^cost 次迭代
  • argon2 的 time / memory / parallelism 参数
  • scrypt 的 N / r / p 参数

核心权衡

  • 工作因子越高 → 暴力破解越慢 → 越安全
  • 工作因子越高 → 每次登录验证越慢 → 用户体验下降、服务器负载上升

推荐基准:单次哈希耗时约 250ms ~ 500ms。用户登录时多等半秒可接受,但攻击者穷举一个 8 位密码需要数十年。

随着硬件进步,工作因子应定期上调(见下文迁移策略)。

内存硬度(Memory Hardness)

传统哈希(bcrypt)主要消耗 CPU。但攻击者可以用 ASIC、FPGA、GPU 专用硬件大规模并行加速,成本低廉。

内存硬度要求哈希过程占用大量内存(如 64MB),使得:

  • 通用 CPU 与 GPU 内存充足,正常工作
  • ASIC / FPGA 要内置大容量显存才能并行,硬件成本呈指数上升
  • 攻击者无法用廉价硬件大规模并行破解

argon2 和 scrypt 都是内存硬算法,这是它们比 bcrypt 更适合新项目的原因。

三大算法详解

bcrypt

诞生于 1999 年,至今仍是使用最广泛的密码哈希算法。

原理:基于 Blowfish 加密算法的 key derivation,通过 cost 参数控制 2^cost 轮迭代。

参数

  • cost:4 ~ 31,推荐 12 ~ 14
  • salt:自动生成 16 字节

输出格式

$2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
  • $2b$:算法标识(2a / 2b / 2y 均为 bcrypt 变体,2b 是修正版)
  • 12:cost 因子
  • 接下来 22 字符是 Base64 编码的盐
  • 最后 31 字符是 Base64 编码的哈希

优点

  • 久经考验,经过 25 年实战检验
  • 库支持广泛(几乎所有语言都有)
  • 抗 ASIC 能力优于 SHA 系列(但仍不如 argon2)

缺点

  • 非 内存硬,GPU 并行破解仍有效
  • 密码上限 72 字节(超出部分被截断,现代长密码场景有隐患)
  • cost 上限 31,长远可能不够

代码示例

// Node.js
const bcrypt = require('bcrypt');

const cost = 12;
const hash = await bcrypt.hash('userPassword123', cost);
// $2b$12$...

const match = await bcrypt.compare('userPassword123', hash);
# Python
import bcrypt

hash = bcrypt.hashpw(b'userPassword123', bcrypt.gensalt(rounds=12))
match = bcrypt.checkpw(b'userPassword123', hash)

argon2

2015 年 Password Hashing Competition 冠军,当前最推荐的密码哈希算法

有三个变体:

  • argon2id(推荐):混合型,兼顾抗侧信道攻击与抗 GPU
  • argon2i:抗侧信道攻击优先,适合密钥派生
  • argon2d:抗 GPU 优先,但有侧信道风险

参数

  • memory:内存消耗(KB),推荐 65536(64MB)或更高
  • time:迭代次数,推荐 2 ~ 3
  • parallelism:并行线程数,推荐 2 ~ 4
  • salt:至少 16 字节

输出格式

$argon2id$v=19$m=65536,t=3,p=4$c2FsdHlzYWx0...$hashbytes...

优点

  • 内存硬度,有效抵御 ASIC / GPU 并行攻击
  • 三维参数(内存 + 时间 + 并行),调优灵活
  • 现代设计,无 72 字节限制
  • 是 OWASP、NIST 当前首推算法

缺点

  • 相对年轻(但已被广泛审计)
  • 部分老旧系统/语言库支持不全

推荐参数(OWASP 2024):

argon2id
  memory: 19 MiB (19456 KB)
  time: 2
  parallelism: 1

更高安全需求可提升到 memory=64MB, time=3, parallelism=4

代码示例

// Node.js (argon2 库)
const argon2 = require('argon2');

const hash = await argon2.hash('userPassword123', {
  type: argon2.argon2id,
  memoryCost: 65536,   // 64 MB
  timeCost: 3,
  parallelism: 4,
});

const match = await argon2.verify(hash, 'userPassword123');
# Python (argon2-cffi)
from argon2 import PasswordHasher, Type

ph = PasswordHasher(
    time_cost=3,
    memory_cost=65536,
    parallelism=4,
    type=Type.ID
)
hash = ph.hash('userPassword123')
match = ph.verify(hash, 'userPassword123')

scrypt

2009 年由 Colin Percival 设计,是第一个主流的内存硬 KDF。

参数

  • N:CPU/内存成本参数,必须是 2 的幂,推荐 2^17 = 131072
  • r:块大小,推荐 8
  • p:并行参数,推荐 1 ~ 4

优点

  • 内存硬设计,优于 bcrypt
  • 算法成熟,库支持较好

缺点

  • 参数调优比 argon2 复杂
  • 在同等安全级别下,argon2id 通常更高效
  • 已不再是 OWASP 首推

代码示例

// Node.js
const scrypt = require('scrypt-js');

// scrypt 同步 API 较复杂,推荐用基于 Promise 的封装
# Python (hashlib.scrypt, 标准库自带)
import hashlib, os, base64

salt = os.urandom(16)
hash = hashlib.scrypt(
    b'userPassword123',
    salt=salt,
    n=2**17,
    r=8,
    p=1,
    dklen=32
)
# 存储: base64(salt) + '$' + base64(hash)

三者对比

维度 bcrypt scrypt argon2id
发布年份 1999 2009 2015
内存硬度
抗 GPU/ASIC 最强
密码长度限制 72 字节
参数维度 1(cost) 3(N/r/p) 3+(m/t/p)
库支持 最广 较广 广
OWASP 推荐 可用 可用 首选
单次哈希耗时(典型配置) ~250ms ~300ms ~300ms
抗侧信道 否(d)/ 是(i 变体无) 是(id 兼具)

如何选择?

  • 新项目:直接用 argon2id,这是当前最优解
  • 已有 bcrypt 且无迁移压力:可继续使用,但下次重构应迁移到 argon2id
  • 已有 MD5 / SHA 系列 / 自制算法立即迁移,这是高危漏洞
  • 受限环境(嵌入式、无 argon2 库):用 bcrypt,cost ≥ 12

迁移策略

场景一:从弱哈希(MD5/SHA)迁移到 bcrypt/argon2

核心思路:懒迁移(Lazy Migration)——用户下次登录时升级哈希,无需批量重算。

1. 用户登录,提交明文密码
2. 服务端用旧算法(如 MD5)计算,与数据库比对
   - 不匹配 → 拒绝登录
   - 匹配 → 进入第 3 步
3. 用新算法(如 argon2id)重新哈希明文密码
4. 更新数据库中的哈希字段
5. 标记该用户已迁移(如 hash_version 字段)

数据库结构:

ALTER TABLE users ADD COLUMN hash_version INT DEFAULT 0;
-- 0 = MD5(待迁移),1 = argon2id(已迁移)

伪代码:

def verify_password(user, plaintext):
    if user.hash_version == 0:
        # 旧算法验证
        if md5(plaintext) != user.password_hash:
            return False
        # 迁移到 argon2id
        user.password_hash = argon2_hash(plaintext)
        user.hash_version = 1
        db.commit()
        return True
    elif user.hash_version == 1:
        return argon2_verify(user.password_hash, plaintext)

优点

  • 不阻塞,无需批量重算所有密码
  • 逐步迁移,老用户登录后自动升级
  • 长期不登录的用户,旧哈希仍可验证

注意:对长期未登录的账户,可考虑强制密码重置。

场景二:提升 bcrypt cost 因子

随着硬件变快,老的 cost 值不再安全。同样用懒迁移:

def verify_password(user, plaintext):
    if bcrypt_check(plaintext, user.password_hash):
        # 检查 cost 是否需要升级
        current_cost = extract_cost(user.password_hash)
        if current_cost < TARGET_COST:
            user.password_hash = bcrypt_hash(plaintext, TARGET_COST)
            db.commit()
        return True
    return False

场景三:从 bcrypt 迁移到 argon2id

def verify_password(user, plaintext):
    if user.hash_version == 0:
        # bcrypt 验证
        if not bcrypt_check(plaintext, user.password_hash):
            return False
        # 升级到 argon2id
        user.password_hash = argon2_hash(plaintext)
        user.hash_version = 1
        db.commit()
    elif user.hash_version == 1:
        return argon2_verify(user.password_hash, plaintext)
    return True

迁移注意事项

  1. 保留旧哈希直到迁移完成——删除前确保所有活跃用户已迁移
  2. 监控迁移进度——统计 hash_version 分布,跟踪未迁移用户数
  3. 通知长期未登录用户——发邮件引导登录以触发迁移,或要求重置密码
  4. 灰度发布——先在小范围验证迁移逻辑正确性,避免误伤用户
  5. 回滚预案——保留旧哈希字段,万一新算法有问题可回退

实现要点与常见坑

1. 使用 CSPRNG 生成盐

# ❌ 不要用 random 模块
import random
salt = str(random.randint(0, 999999))

# ✅ 用密码学安全的随机数
import os
salt = os.urandom(16)

bcrypt / argon2 库的 hash() 方法会自动用 CSPRNG 生成盐,无需手动处理。

2. 常量时间比较

比较哈希时必须用常量时间函数,防止时序攻击:

# ❌ 普通 == 比较,可通过耗时推断
if user_hash == input_hash:

# ✅ 常量时间比较
import hmac
if hmac.compare_digest(user_hash, input_hash):

bcrypt / argon2 库的 verify() 内部已是常量时间实现,直接调用即可。

3. 密码长度上限

  • bcrypt:72 字节截断。若用户密码超长,建议先 SHA-256 再 bcrypt(但会丢失 bcrypt 对超长密码的强度,需评估)
  • argon2 / scrypt:无实际限制

4. 并发与性能

密码哈希是 CPU 密集型操作:

  • 登录请求会占用 CPU ~250ms,高并发下可能成为瓶颈
  • 限制登录接口速率(如每 IP 每分钟 10 次),防暴力破解耗尽资源
  • 考虑异步队列处理批量哈希任务

5. 错误信息不要泄漏

# ❌ 泄漏用户是否存在
if user not found:
    return "用户不存在"
if password wrong:
    return "密码错误"

# ✅ 统一错误信息
return "用户名或密码错误"

6. 存储格式

推荐把算法、参数、盐都编码进单个字符串,便于迁移与多算法共存:

$argon2id$v=19$m=65536,t=3,p=4$base64salt$base64hash
$2b$12$22charsalt31charhash

解析时根据前缀判断算法,分别调用对应验证逻辑。

总结

维度 最佳实践
新项目算法 argon2id(OWASP 首推)
存量 bcrypt 可保留,下次重构迁移
MD5 / SHA / 明文 立即迁移,高危
每用户唯一,CSPRNG 生成,≥16 字节
工作因子 单次哈希 ~250-500ms
内存硬度 argon2 / scrypt 优于 bcrypt
迁移方式 懒迁移(登录时升级)
cost 调优 定期评估,随硬件提升而上调
比较方式 常量时间比较
错误信息 统一"用户名或密码错误"

密码哈希是账户安全的基石。选对算法、设好参数、做好迁移,才能在数据库泄漏时真正保护用户。永远不要自己发明哈希算法——用经过审计的标准库,跟随 OWASP 的最新建议。

← 返回博客首页