阅读提要

密码不能加密,只能哈希。但哈希函数选错、成本因子定错,等于给自己埋一颗定时炸弹——三年前的参数,今天可能已经不安全。

开始阅读

字号

三条底线

不能加密。 加密可逆,密钥泄露等于密码全泄。密码只能用单向哈希。

不能裸哈希。 直接 sha256(password) 的问题是:同样的密码得到同样的哈希,攻击者可以用彩虹表批量比对。必须加盐,每个用户独立的随机盐。

不能自己实现。 密码哈希有专门的算法(bcrypt、scrypt、argon2),它们的共同特点是故意慢,并且可调。用通用哈希函数,等于给暴力破解提速。

口令存储

为什么是 bcrypt

bcrypt 有三个特性:

  1. 内置加盐:每次哈希自动生成随机盐,存在结果里;
  2. 可调成本:成本因子每加 1,计算时间翻倍;
  3. 内存访问模式固定:不如 argon2 抗 GPU,但实现简单、库成熟。

对普通站点足够。如果要从头选,argon2id 更现代;但 bcrypt 兼容性最好,bcryptjs 这类纯 JS 实现也能用(速度慢一些,对服务端反而无所谓)。

成本因子怎么定

这是最容易被拍脑袋定错的地方:很多人抄一个 10 就再也没改过。

正确的定法只有一个:测量。

import bcrypt from 'bcryptjs'

for (const rounds of [10, 11, 12, 13, 14]) {
  const start = performance.now()
  await bcrypt.hash('test-password', rounds)
  console.log(rounds, (performance.now() - start).toFixed(0), 'ms')
}

结果大概是每加 1 翻倍:10 约 60ms、12 约 240ms、14 约 1 秒(具体取决于机器)。

选择标准:单次哈希耗时在 100-250ms 之间。低于 100ms 太弱,高于 250ms 登录接口会成为 DoS 的入口——攻击者用大量登录请求把你的 CPU 打满。

在你自己的服务器上测,不要用开发机的数字。2 核 VPS 和 M4 的差距是数量级。

存储格式

bcrypt 的输出自带算法标识、成本因子和盐:

$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
 │   │  │                     │
 │   │  └ 22 字符盐 + 31 字符哈希
 │   └ 成本因子 12
 └ 版本

所以存一个字段就够,不需要单独存盐。校验时直接比对:

const ok = await bcrypt.compare(inputPassword, user.passwordHash)

compare 会从哈希里读出成本和盐,自动用同样的参数计算。

换算法时怎么平滑迁移

不能要求所有用户重设密码。做法是「登录时静默升级」:

async function verify(user, input) {
  const ok = await bcrypt.compare(input, user.passwordHash)
  if (!ok) return false

  // 成本因子偏低,用新参数重新哈希
  if (needsRehash(user.passwordHash)) {
    const fresh = await bcrypt.hash(input, CURRENT_ROUNDS)
    await db.update(users).set({ passwordHash: fresh }).where(eq(users.id, user.id))
  }
  return true
}

function needsRehash(hash) {
  return bcrypt.getRounds(hash) < CURRENT_ROUNDS
}

用户无感,几个月后大部分活跃用户都升到了新参数。不活跃的账号保持旧参数——风险可控,因为他们的密码本来就不常被使用。

三个常见错误

第一,把「慢」当成 bug 去优化。 有人看到登录要 200ms,就把成本降到 4,理由是「用户体验」。这等于把密码存储降级成玩具。

第二,忘了限制尝试次数。 哈希再慢,攻击者也可以无限次尝试。必须配套:按账号 + IP 限流、失败若干次后锁定或触发验证码、记录失败日志。

第三,日志里打印了密码。

console.log('login attempt', { username, password })   // 灾难

这条日志会进 journald、进日志平台、进备份。一次疏忽,全站用户的密码明文躺在磁盘上。在日志库层面加 redact 规则,不要依赖开发者自觉。

还有一个常被忽略的

密码长度上限。bcrypt 只处理前 72 字节,超出的部分被静默截断。用户以为自己的 100 位密码更安全,实际只有前 72 字节生效。

两种处理:限制最大长度(比如 64 字符)并在 UI 上说明;或者先 sha256 再 bcrypt(注意这是「预处理」,不是「替代」)。

收尾清单

  1. 用 bcrypt / scrypt / argon2,不用 sha256;
  2. 成本因子在本机实测,单次 100-250ms;
  3. 只在登录时静默升级参数;
  4. 失败次数必须限流;
  5. 日志必须脱敏;
  6. 明确长度上限。

六条做到,密码存储这一块基本就不会出事了。

你认为这篇文章怎么样?

还没有表情反馈

分享文章