
书接上回,选定 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 边缘环境下,每个请求都受到严格的资源配额约束,必须想办法节省性能开销。
当时摆在我面前的有两条路:
- 升级到付费版:购买每月 $5 的 Workers Paid 计划,CPU 上限放宽到 50ms,问题当场解决;
- 调低迭代次数:把 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)
兼顾速度与安全的原因
- 计算耗时低:一次 HMAC-SHA256 计算耗时不到 0.05ms,加上 5 万次 PBKDF2 迭代,总 CPU 时间稳定在 6 到 8ms,能完全装进 10ms 配额内。
- 防脱机碰撞:即便 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,顺利完成了初始化。
解决了初始化的算力限制后,接下来就是把旧博客里的文章、独立页面、短篇随笔和评论数据迁移过来。