设计笔记
随笔 约 7 分钟阅读

10ms 魔咒 —— PBKDF2 降档与 HMAC Pepper 安全加固

PortingTypecho2Workers

书接上回,选定 Typecho-CF 之后,我兴冲冲地把它打包部署到了 Cloudflare Workers 上,本以为马上就能用上,没想到出师不利,初始化这一步就被卡住了。

代码发布上去后,我打开绑定的域名进入 /install 页面,输入站点名称、管理员账号和密码,点击“开始安装”。浏览器转圈两三秒,随后弹出了一个红色错误页:

Error 1102: Worker exceeded CPU time limit.

博客没能初始化成功,数据库里的初始表和管理员账号也只写了一半。


为什么会 CPU 超时?

我把当时的 Worker 运行日志和安装代码发给 AI 协助排查。AI 顺着调用栈逐行分析,很快指出了问题所在:在创建管理员账号时,密码哈希计算耗尽了 CPU 时间。

Cloudflare Workers 对请求有严格的算力限制:

  • Free 计划:单个请求的纯 CPU 时间上限为 10ms
  • Paid 计划:默认 50ms,最高可调整至 3000ms。

这里的 10ms 指的是 CPU 实际计算时间,等待数据库读写或网络 IO 的时间并不计入其中。

原项目为了遵循 OWASP 2024 关于密码存储的安全标准,默认采用了 600,000 次 PBKDF2-HMAC-SHA256 迭代:

export const PBKDF2_ITERATIONS = 600_000;

在本地开发机或独立服务器上,跑 60 万次哈希大概需要 30 到 50ms。因为用户注册和登录并不频繁,这点开销在传统服务器上完全可以忽略。

但在 Workers Free 计划的单核沙箱环境里,CPU 算力相对有限,这 60 万次哈希计算耗时在 40ms 到 60ms 之间。执行到 10ms 阈值时,Worker 就会被运行时直接终止,抛出 1102 错误。


两难的选择

在传统的服务器开发环境里,60 万次迭代甚至更高的计算成本是非常普遍的做法,开发者通常不需要为几毫秒的 CPU 开销而顾虑。但在 Serverless 边缘环境下,每个请求都受到严格的资源配额约束,必须想办法节省性能开销。

当时摆在我面前的有两条路:

  1. 升级到付费版:购买每月 $5 的 Workers Paid 计划,CPU 上限放宽到 50ms,问题当场解决;
  2. 调低迭代次数:把 PBKDF2 迭代压到 50,000 次以内,让计算耗时控制在 8ms 左右。

我折腾这套方案的初衷就是验证免费配额下运行完整博客的可行性,直接花钱升级违背了最初的目标。

但如果只是简单地把迭代次数砍到 5 万次,密码的抗碰撞强度就会明显下降。万一后续数据库备份不慎泄漏,攻击者在本地使用显卡跑暴力破解的成本会大大降低。


解决方案:50k 迭代 + HMAC Pepper

为了在 10ms 配额内兼顾运算速度与密码安全,我采用了 Pepper(密码胡椒)方案。

什么是 Pepper?

常见的 Salt(盐)是随机生成的,明文存放在数据库的用户表里,主要用来防范彩虹表。

Pepper 则是一个独立于数据库的高熵密钥,保存在 Cloudflare Workers 的安全环境变量 PASSWORD_PEPPER 中。

计算密码哈希时,先用 PASSWORD_PEPPER 对原始明文密码做一次 HMAC-SHA256 计算,再把输出结果送入 50,000 次 PBKDF2 进行迭代加盐计算。

[明文密码] → [HMAC-SHA256] (Pepper 密钥) → [PBKDF2-SHA256] (50k 迭代 + 16B Salt) → [$PBKDF2P$50000$salt$hash] (写入 D1)

兼顾速度与安全的原因

  1. 计算耗时低:一次 HMAC-SHA256 计算耗时不到 0.05ms,加上 5 万次 PBKDF2 迭代,总 CPU 时间稳定在 6 到 8ms,能完全装进 10ms 配额内。
  2. 防脱机碰撞:即便 D1 数据库被全量导出,只要保存在 Cloudflare 环境变量里的 PASSWORD_PEPPER 没有泄露,攻击者在本地拿到 hash 和 salt 也无法直接构建字典进行离线暴力破解。

代码实现与平滑升级

src/lib/auth.ts 中,我通过哈希前缀来区分是否启用了 Pepper:

  • 无 Pepper 格式:$PBKDF2$<iterations>$<salt>$<hash>
  • 有 Pepper 格式:$PBKDF2P$<iterations>$<salt>$<hash>
export async function hashPassword(password: string): Promise<string> {
  const iterations = getPbkdf2Iterations(); // 默认 600,000,Free 模式读取配置 50,000
  const pepper = env.PASSWORD_PEPPER?.trim();
  const salt = crypto.getRandomValues(new Uint8Array(16));
  
  // 1. 若配置了 Pepper,先执行 HMAC-SHA256 预哈希
  const payload = pepper
    ? await hmacSha256(pepper, password)
    : new TextEncoder().encode(password);

  // 2. 执行 PBKDF2 派生密钥
  const derived = await pbkdf2Sha256(payload, salt, iterations, 32);
  const saltHex = bufferToHex(salt);
  const hashHex = bufferToHex(derived);

  // 3. 带 P 前缀标记
  const prefix = pepper ? '$PBKDF2P$' : '$PBKDF2$';
  return `${prefix}${iterations}$${saltHex}$${hashHex}`;
}

登录时自动升级哈希

当用户登录成功时,程序会调用 passwordHashNeedsRehash(user.password) 进行检测:

  • 如果当前配置了 Pepper,但数据库里的记录还是旧版的 $PBKDF2$
  • 或者数据库里的迭代次数低于当前配置的 PBKDF2_ITERATIONS

系统会在登录成功后用新标准重新生成哈希并更新到数据库中,不需要手动重置密码。

if (passwordHashNeedsRehash(user.password)) {
  const upgradedHash = await hashPassword(password);
  await db.update(schema.users)
    .set({ password: upgradedHash })
    .where(eq(schema.users.uid, user.uid));
}

配置方式

如果你同样运行在 Cloudflare Workers Free 计划上,可以在 wrangler.toml 中配置环境变量:

[vars]
PBKDF2_ITERATIONS = "50000"

同时在 Workers 后台添加 Pepper 密钥:

wrangler secret put PASSWORD_PEPPER

结果

调整之后,再次执行 /install 安装流程,整个接口耗时从超时失败降至约 7ms,顺利完成了初始化。

解决了初始化的算力限制后,接下来就是把旧博客里的文章、独立页面、短篇随笔和评论数据迁移过来。