密码 hash 用 argon2id:替代 bcrypt / scrypt 的现代选择

起因

老项目用 bcrypt(rounds=10) 存密码。安全审计建议升级到 argon2id:

bcrypt 是 1999 年算法,对 GPU 暴力破解抗性已弱。
argon2id 是 2015 年 Password Hashing Competition 冠军,
memory-hard,专门抗 GPU/ASIC 攻击。

OWASP 2024 推荐:新项目用 argon2id;老项目升级时 graceful 迁移。

解决方案

1. Python: argon2-cffi

uv add argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError

# 单例 hasher
ph = PasswordHasher(
    time_cost=3,        # 迭代次数
    memory_cost=65536,  # 64 MB
    parallelism=4,      # 并行度
    hash_len=32,
    salt_len=16,
)

# 注册时
def register(email: str, password: str):
    h = ph.hash(password)   # 自动 salt
    db.execute('INSERT INTO users (email, password_hash) VALUES (%s, %s)',
               (email, h))

# 登录时
def verify(email: str, password: str) -> bool:
    h = db.fetchone('SELECT password_hash FROM users WHERE email=%s', (email,))
    if not h:
        return False
    try:
        ph.verify(h[0], password)
        # 如果参数升级了,自动 rehash
        if ph.check_needs_rehash(h[0]):
            new_h = ph.hash(password)
            db.execute('UPDATE users SET password_hash=%s WHERE email=%s',
                       (new_h, email))
        return True
    except VerifyMismatchError:
        return False

hash() 输出形如:

$argon2id$v=19$m=65536,t=3,p=4$<salt-b64>$<hash-b64>

参数(m / t / p)编码在 hash 里,verify 时不需要单独存。

2. 推荐参数

OWASP 2024 推荐(适合典型 web 服务器):

PasswordHasher(
    time_cost=3,
    memory_cost=12288,    # 12 MB(低规模服务器)
    parallelism=1,
)

memory_cost 单位 KiB。生产服务器 RAM 充足建议:

  • 64-128 MB / hash
  • time_cost 3-5
  • parallelism 1(避免 thread 争抢)

调参思路:在你的服务器上测 ph.hash('test') 耗时,目标 250-500 ms。
太快 → 攻击者 GPU 一秒破百万;太慢 → 用户登录卡。

3. 渐进迁移:bcrypt → argon2id

用户表里 hash 共存:

from argon2 import PasswordHasher
import bcrypt

ph = PasswordHasher()

def verify(email, password):
    h = db.get_user(email).password_hash
    if h.startswith('$argon2'):
        try: ph.verify(h, password); ok = True
        except: ok = False
    elif h.startswith('$2'):     # bcrypt
        ok = bcrypt.checkpw(password.encode(), h.encode())
    else:
        ok = False

    if ok and not h.startswith('$argon2'):
        # 用户登录成功 + 老 hash → 趁机升级
        new_h = ph.hash(password)
        db.update_user_hash(email, new_h)

    return ok

不需要"全部用户重设密码",每次登录顺便升级。3-6 个月几乎全部用户
迁完。

4. Go: argon2id

go get github.com/alexedwards/argon2id
import "github.com/alexedwards/argon2id"

params := &argon2id.Params{
    Memory:      64 * 1024,
    Iterations:  3,
    Parallelism: 1,
    SaltLength:  16,
    KeyLength:   32,
}

hash, err := argon2id.CreateHash(password, params)
// 存 hash 进 DB

// 验证
match, err := argon2id.ComparePasswordAndHash(password, hash)

5. Node.js: argon2

npm i argon2
import argon2 from 'argon2'

const hash = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 65536,
  timeCost: 3,
  parallelism: 1,
})

const ok = await argon2.verify(hash, password)

6. 用 passlib(Python 老项目)

Django 也用 passlib 兼容多算法:

# Django settings
PASSWORD_HASHERS = [
    'django.contrib.auth.hashers.Argon2PasswordHasher',
    'django.contrib.auth.hashers.PBKDF2PasswordHasher',  # 兼容老 hash
    'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
]

Django 验密时按顺序尝试匹配;用户登录成功后自动 rehash 到第一个
hasher(argon2id)。零代码迁移。

选择对比

bcrypt scrypt PBKDF2 argon2id
年代 1999 2009 2000 2015
GPU 抗性 强(memory-hard)
算法标准化 事实标准 RFC 7914 NIST RFC 9106
推荐参数 rounds=12+ N=2^15+ iters=600k+ t=3,m=64MB
库支持 极广 广 广 良好(增长中)

2024 后新项目首选 argon2id;老项目迁移路径如上。

配套安全实践

1. 永远 hash + salt,不要明文

# ❌
db.execute('INSERT ... VALUES (%s, %s)', (email, password))

# ❌ 简单 hash(无 salt 易 rainbow table 攻击)
db.execute('INSERT ... VALUES (%s, %s)', (email, hashlib.sha256(password.encode()).hexdigest()))

# ✅
db.execute('INSERT ... VALUES (%s, %s)', (email, ph.hash(password)))

2. 等时比较

ph.verify() 内部用 constant-time compare。手写 hash == expected
有时序泄漏。

3. 不要 log 密码 / hash

# ❌
log.info('login attempt: user=%s pw=%s', email, password)

# ✅
log.info('login attempt: user=%s', email)

4. 限制登录失败

按 IP / 按 email 限速:

  • 5 分钟内 5 次失败 → 拒绝 15 分钟
  • 配合 captcha

5. 强密码策略

不强求复杂度,但建议:

  • 最少 12 字符
  • 用 zxcvbn / haveibeenpwned API 检查"是否已泄漏"
import requests
def is_pwned(password: str) -> bool:
    sha = hashlib.sha1(password.encode()).hexdigest().upper()
    prefix, suffix = sha[:5], sha[5:]
    r = requests.get(f'https://api.pwnedpasswords.com/range/{prefix}')
    return suffix in r.text

只发 prefix 5 位(k-anonymity),不泄漏完整 hash。

6. 不要"密码找回 = 显示原密码"

如果你能给用户发"你的密码是 xxx",说明密码明文存。删库跑路风险。
正确做法:reset 链接让用户重设新密码。

效果

我们升级后:

  • 5000 用户在 4 个月内 95% 自然 rehash 到 argon2id
  • 安全审计 pass
  • 单次登录验证延迟从 50ms → 350ms(增加但仍可接受)
  • 服务器 RAM 占用增加 ~50MB / 并发 login(PasswordHasher 实例)

踩过的坑

  1. memory_cost 单位是 KiB:写 65536 是 64 MB,不是 64 KB。
    写错值参数提示模糊。

  2. 每个 worker 都创建 hasher:PasswordHasher() 实例化贵。
    全局单例。

  3. rehash 升级时不告诉用户:用户体验无感(好事)。但要 log
    "user X migrated to argon2id" 供审计。

  4. password 长度无限:Argon2 自身能处理任意长度,但 DB column
    设 VARCHAR(255) 限制 hash 输出(~100 字符)+ 限输入密码 ≤ 128 字符
    防 DoS(超长密码 hash 时间长)。

  5. 跨实例参数不一致:你的 web server 用 64MB,别人在 mobile API
    用 12MB,同密码 verify 应该都 work(参数编码在 hash 里)。
    但 hash 操作如果 mobile API 太慢,统一参数到最小公倍数。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。