编程 Argon2id 参数别照抄默认值:64 MiB 乘 40 个 worker 就 OOMKill,PHP 与 Go 的实现差异

2026-10-28 00:04:46

Argon2id 的 m/t/p 怎么选:先算内存账,再处理 PHP-FPM 与 Go 实现里的问题

一次登录高峰的 OOMKilled

某个登录接口在大促当天 502。查 kubectl describe pod,容器 OOMKilled,memory limit 2Gi;Nginx 侧是 upstream prematurely closed connection。CPU 利用率不高,QPS 也不算离谱,但每个 PHP-FPM worker 在被 Argon2id 分配约 64 MiB 内存后,再叠加 pm.max_children=40 就超过 2 GiB:40 × 64 = 2560 MiB,还没算 PHP 自身和框架的开销。进程被内核杀死后,前端看到 502;流量并没有把服务打垮。

参数来自 PASSWORD_ARGON2ID 的默认值。问题不在于是否启用 Argon2id,而在于没有把内存和进程数放在一起算。

m / t / p 各管什么

  • m:总内存,单位 KiB。memory_cost=65536 就是 64 MiB,不是 64 KiB,也不是 64 MB。
  • t:对内存的 pass 次数,迭代多少轮。与内存分配量无关。
  • p:并行度,把内存切成多少条 lane。OWASP 的原话是 “Parallelism controls the number of lanes over which the memory is divided; increasing p does not multiply the total memory or act as an iteration count”。p 不乘内存,它既不是并发数,也不是迭代次数。

RFC 9106 §4 给的是两档推荐:t=1, p=4, m=2^21(2 GiB)和 t=3, p=4, m=2^16(64 MiB)。OWASP 现行的五档最低配置是另一套东西:

mtp
47104 (46 MiB)11
19456 (19 MiB)21
12288 (12 MiB)31
9216 (9 MiB)41
7168 (7 MiB)51

这五档防御等级相同,只是 CPU 与 RAM 的取舍。它们来自 CheatSheetSeries issue #1183 里的基准:把攻击者限制在 10 kH/s 以下时,bcrypt cost 9、PBKDF2-HMAC-SHA512 210000 次、Argon2 m=12288/t=3/p=1、scrypt N=2^14/r=8/p=5 大致相当。所以不要把 RFC 的 2 GiB 当成“官方推荐配置”直接上生产。

p=4 在服务端尤其要谨慎。讨论里 Sc00bz 指出 RFC 那两档并不等价(等价计算量要到 t=32),而且 p=4 时实际只有 2 核在跑。对防守方来说,p 最多设成实际会用到的核数,设高了等于给攻击方优势。服务器关心的是吞吐,不是单次哈希的极限强度,所以服务端用 p=1,或者干脆加排队系统限制同时运行的哈希数量。OWASP 也提醒:工作因子应以单次哈希小于 1 秒为界,过高会让攻击者用大量登录请求消耗 CPU,做 DoS。

内存预算:一个乘法

峰值内存大致是:

峰值 ≈ pm.max_children × memory_cost(KiB) + 常驻开销

pm.max_children=40、memory_cost=65536 就是 2560 MiB;同样进程数下换成 OWASP 的 19456,是 760 MiB。这就是两者差一个数量级的全部原因。

Go 侧可以直接对应上。x/crypto/argon2 的 initBlocks 里是 B := make([]block, memory),block 类型为 [128]uint64,即 1024 字节一个 block。所以 memory 的 KiB 数就是 block 数量,也就是一次 IDKey 调用的实际分配量。并发 N 个登录请求,就是 N 次这样的 make。

容器 memory limit 要在上面这个乘积之外再留余量:PHP-FPM 的 memory_limit 统计不到 libargon2/libsodium 在 C 层 malloc 的这块内存,容器级 OOMKilled 不会因为 memory_limit 没超就不发生。给 limit 留 25%–30% 的余量,比事后调 pm.max_children 便宜。

延迟预算:交互式登录单次 250–500 ms 可接受,PHP 手册给的参考值是低于 350 ms。这个数字决定了内存和迭代的取舍方向。

PHP 8.x 的具体行为

默认值。PASSWORD_ARGON2ID 在 PHP 8.x 下是 memory_cost=65536 KiB、time_cost=4、threads=1(对应 PASSWORD_ARGON2_DEFAULT_MEMORY_COST / _TIME_COST / _THREADS)。这是一个对并发不友好的默认值:单看一次哈希没问题,乘上 worker 数就出问题。

threads 在 libsodium 实现下无效。手册明确写了:threads 仅在 PHP 使用 libargon2 时可用,libsodium 实现下不可用。PHP 7.4.0 起 sodium 扩展提供 sodium_crypto_pwhash_str 这条另一条路径,两条路径的参数语义不完全一样。要确认编译时用的是哪个实现,否则在配置里改 threads 可能完全没效果。

password_needs_rehash 必须传同一套 options:

$options = ['memory_cost' => 19456, 'time_cost' => 2, 'threads' => 1];

if (password_needs_rehash($row['password_hash'], PASSWORD_ARGON2ID, $options)) {
$row['password_hash'] = password_hash($plain, PASSWORD_ARGON2ID, $options);
}

不传 $options 时它拿默认值去比较,于是你以为在升级参数,实际上永远判定“不需要”。只传 memory_cost 不传 time_cost 同理。

PHP 8.4 改了 bcrypt 默认 cost:PASSWORD_BCRYPT 的默认 cost 从 10 提到 12。存量代码里如果 password_hash($pwd, PASSWORD_BCRYPT) 没显式写 cost,升级 PHP 版本之后新注册用户的哈希成本会跳一档,登录耗时和 CPU 跟着变。这不是 Argon2 的问题,但同一批上线里经常被一起带出来。

PHC 字符串格式:

$argon2id$v=19$m=19456,t=2,p=1$$

salt 至少 8 字节,推荐 16 字节。password_verify 会从字符串里读算法、参数和 salt,所以不需要单独存 salt。反过来说,校验必须按字符串里的参数算:自己解析 PHC 或在 Go 里手写比对时,要从串里读出 m、t、p 和 salt 再调 IDKey,不能拿服务当前配置去算一遍再和串里的 hash 比,那样只要参数改过就全部验不过。

Go x/crypto/argon2 的具体行为

签名:

func IDKey(password, salt []byte, time, memory uint32, threads uint8, keyLen uint32) []byte

deriveKey 里有两件事会改变你传进去的值:

if time  $m, 'time_cost' => $t, 'threads' => 1];
$start = microtime(true);
for ($i = 0; $i  %.1f ms\n", $m, $t, $ms);
}

Go benchmark:

func BenchmarkIDKey(b *testing.B) {
password := []byte("correct horse battery staple")
salt := []byte("0123456789abcdef") // 16 字节
cases := []struct {
name    string
t, m    uint32
p       uint8
}{
{"m=19456,t=2,p=1", 2, 19456, 1},
{"m=47104,t=1,p=1", 1, 47104, 1},
{"m=12288,t=3,p=1", 3, 12288, 1},
}
for _, c := range cases {
b.Run(c.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
argon2.IDKey(password, salt, c.t, c.m, c.p, 32)
}
})
}
}

b.ReportAllocs() 会直接显示每次调用分配的字节数,可以用来核对上面说的取整行为。

从 bcrypt 迁过来

存量 bcrypt 哈希没法离线批量转换,只能在登录时重哈希:验证旧哈希通过 → 立刻用新参数 password_hash 重算 → 更新该行 → 用户无感。password_needs_rehash 可以同时覆盖“从 bcrypt 迁到 Argon2id”和“Argon2id 参数升级”两种情况,只要 options 传对。

如果哈希库已经泄漏,升级参数不够,token 和密码都必须强制失效重置,走找回流程。

另一件事:不要为了“更安全”把 RFC 那档 2 GiB 参数直接上生产。服务端一次登录分配 2 GiB,几个并发就是 OOMKill;p=4 还未必有 4 核在跑。OWASP 的 19 MiB / t=2 / p=1 是给服务端的起点,不是下限里最保守的那个。

上线前的三条检查

  1. 算一遍内存:pm.max_children × memory_cost 加上常驻开销,再留 25%–30%,和容器 limits.memory 对得上;顺带确认 pm.max_children 没有被静态值和动态值两处配置覆盖。
  2. 抽一条线上 PHC 串:$argon2id$v=19$m=...,t=...,p=...$... 里的参数和目标配置一致,password_needs_rehash 的调用点确实传了同一套 $options。
  3. 在目标机型上跑一遍 benchmark:p95 登录耗时在 250–500 ms(PHP 参考 350 ms),登录接口有限流或排队,避免高并发时多个哈希同时占用内存和 CPU。

参考:OWASP Password Storage Cheat Sheet、CheatSheetSeries issue #1183、PHP password_hash 手册、golang.org/x/crypto/argon2。

复制全文 生成海报 密码哈希 Argon2id PHP Go 安全

推荐文章

程序员茄子在线接单