Go 用户密码存储实践:从 MD5 到 Argon2id

引言

学生时期,我曾写过一篇关于 Go 密码哈希算法的技术文章。当时将 MD5、PBKDF2、bcrypt、scrypt 和 Argon2 放在一起进行 Benchmark,试图通过性能对比寻找合适的密码存储方案。

现在回头看,实验本身没有太大问题,但关注点有些偏了。

对于普通哈希算法,快通常是优点;而对于用户密码存储,太快反而可能成为安全问题。

一旦用户数据库泄露,攻击者便可以绕过登录接口,在本地批量猜测密码。此时真正重要的不是谁计算得更快,而是如何在正常用户可以接受的验证延迟内,尽可能提高攻击者的离线猜测成本。

这次我重新参考 OWASP、NIST 和 RFC 9106 的安全建议,使用自己的 Go 开源库 easyhash,在 Windows 和 Linux 两种环境下完成了正确性、算法性能、Argon2id 参数与并发资源开销实验。

本文将结合实际代码与测试数据,讨论密码哈希算法应该如何选择、成本参数如何确定,以及安全性与服务器资源之间的工程取舍。

1. 为什么不能直接用 MD5 / SHA-256?

先假设一件不愉快的事:某个网站的用户数据库泄露了。

如果数据库保存明文密码,攻击者直接拿到了答案。如果保存的是 SHA256(password),攻击者虽然不能直接"解密"哈希,却可以在自己机器上不断猜测候选密码,计算 SHA-256,与泄露的结果比对。

更麻烦的是,这种离线猜测不经过登录接口。验证码、API 限流和账号锁定都无法直接约束已经拿到哈希文件的攻击者。

MD5、SHA-256 是高性能的通用哈希函数;速度是它们在文件完整性检查等场景中的优点,却不适合直接保护通常熵值有限的人工设置密码。即使为 SHA-256 加上随机 Salt,也只是阻碍预计算和相同密码的哈希关联,并不会让每次猜测本身变得足够昂贵。此外,MD5 还有已知的碰撞安全缺陷,但密码存储里最直接的问题是它的计算成本太低。

密码存储需要的恰恰是另一种设计:让合法用户偶尔验证一次的代价可接受,让攻击者验证海量候选密码的代价尽可能高。

2. 应该选择哪种密码哈希?

技术选型可以从主流算法的工程定位出发:

算法 特点 使用建议
Argon2id 可调节内存、迭代次数和并行度,具备内存硬化特性 新系统优先评估
scrypt 具备内存硬化特性 Argon2id 不可用时考虑
bcrypt 使用 Cost 调节计算成本,生态成熟,但存在 72 字节输入限制 既有系统兼容及迁移
PBKDF2-HMAC-SHA256 主要通过迭代次数提高计算成本 需要相应合规或兼容性时考虑
MD5 / 单轮 SHA-256 计算速度快,不是专用密码哈希方案 不用于新密码存储

具体参数不应该只凭直觉设置:

  • OWASP 提供面向工程实践的参考下限,例如 Argon2id m=19 MiB, t=2, p=1;scrypt N=2^17, r=8, p=1;bcrypt 最低 Cost 10;PBKDF2-HMAC-SHA256 至少 600,000 次。
  • NIST SP 800-63B-4 强调抗离线攻击的密码存储、独立 Salt、保存算法及成本信息,并建议在不损害服务性能的前提下尽可能提高成本,随计算能力发展进行升级。它没有规定一个适用于所有算法的统一迭代轮数。
  • RFC 9106 为 Argon2id 提供了不同资源预算下的推荐配置,例如内存受限时的 64 MiB, t=3, p=4。

这些建议面向的资源预算与合规目标并不完全相同。t=3 也不意味着跨算法比 600,000 次 PBKDF2 更弱;迭代次数脱离具体算法没有比较意义。

3. 用 Go 实现密码生成与验证

Go 的 golang.org/x/crypto/argon2 提供 argon2.IDKey。但生产使用不能只计算一段字节数组,还需要安全随机 Salt、参数校验、编码存储、正确验证以及未来的参数迁移。

我的 easyhash v1.2.0 封装了这些步骤。这里明确选择 Argon2id,而不是使用 Hash() 的默认算法------easyhash 当前默认是 bcrypt Cost 12。

go 复制代码
package main

import (
    "fmt"
    "log"

    "github.com/gofurry/easyhash"
)

func main() {
    cfg := easyhash.Argon2{
        TimeCost: 2, MemoryKiB: 19 * 1024,
        Threads: 1, KeyLen: 32, SaltLen: 16,
    }
    password := "example-password-for-demo"

    stored, err := easyhash.Hash(password, easyhash.WithArgon2idConfig(cfg))
    if err != nil {
        log.Fatal(err)
    }

    ok, err := easyhash.Verify(password, stored)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("verified:", ok)
}

库内部使用 crypto/rand 为每条密码记录生成独立的随机 Salt,并在验证时取出已有 Salt 和成本参数重新计算。密码本身不需要、也不能通过"解密哈希"取回。

easyhash 使用自描述的 $easyhash$v=1$... 存储格式,包含算法、参数、Salt 和派生结果。它不是标准 Argon2 PHC 格式,跨库迁移时必须注意兼容性。把参数和版本与哈希一起保存,是为了以后能够识别旧配置并升级,而不是把成本写死在业务代码里。

4. 实验:计算成本究竟相差多少?

实验使用相同的测试密码、固定的库版本 easyhash v1.2.0 和 Go 1.27.1。两套环境分别是:

  • Windows 10:Ryzen 7 5800H,8 核 16 线程,GOMAXPROCS=16。
  • Linux 沙箱:EPYC 虚拟 CPU,约 4 核 CPU 配额,GOMAXPROCS=4,4 GiB 容器内存上限。

Linux 二进制由 Windows 交叉编译,两套使用相同 Go 源码和 easyhash 版本。操作系统、CPU 配额及虚拟化环境不同,因此这不是严格控制变量的硬件排行榜。

图注:三轮 Benchmark 的平均单次生成耗时,包含生成 Salt 与编码开销。不同算法采用明确但并不等强的参考参数,不能以耗时给安全性排名。

在 Windows 上,裸 SHA-256 的平均耗时约 77.6 ns ,Argon2id(19 MiB / t2 / p1)约 21.95 ms ,相差约 28 万倍。Linux 上分别为 93.4 ns 和 22.17 ms,同样相差多个数量级。

bcrypt Cost 10 与 Cost 12 在 Windows 上分别约为 51.4 ms 和 211.1 ms,后者约为前者的四倍,符合 Cost 每增加一级、工作量约翻倍的特征。

但要小心:图中的 Argon2id 看起来最快,不等于它"安全性最低";scrypt 最慢,也不等于它"最安全"。这些配置的内存成本与抵抗大规模并行猜测的机制并不相同。我们没有测试 GPU / ASIC 破解成本,也不能从 CPU 耗时直接推导密码破解速度。

5. Argon2id:内存、迭代和并行度的取舍

Argon2id 的三个核心参数是:m(总内存成本,KiB)、t(遍历内存的次数)、p(并行通道数)。它们不是三个相互独立的"安全等级旋钮",而是共同影响运行成本。

图注:六组参数在两套环境下各重复三轮的平均值,实验使用相同密码及输出长度。

在 Windows 上,把 m 从 19 MiB 增加到 64 MiB,保持 t=2, p=1,耗时从 19.85 ms 上升到 68.05 ms ;将 t 从 2 提高到 3,又上升到 101.20 ms。

相同的 64 MiB, t=3 下,p=1 / 2 / 4 的耗时分别约为 101.2 / 57.5 / 38.1 ms 。更高并行度缩短了单次等待时间,但并不会把总内存参数从 64 MiB 变成 64×4 MiB。

这组结果很好地说明了:参数调整是计算时间、内存预算和 CPU 并行能力之间的取舍,不能简单地盲目增加迭代次数。

6. 单次更快,不代表并发容量更大

真实登录服务还有另一个问题:如果许多用户同时验证密码,会发生什么?

我们使用固定工作线程数模拟并发验证,在两套环境里分别测试 1、2、4、8 并发。这里没有 HTTP、数据库与实际登录链路,每组约 10 秒,而且只运行了一轮,所以这是一组探索性压力样本,不是生产 SLA。

图注:固定 m=64 MiB, t=3,对比 p=2 与 p=4;Linux 沙箱约 4 核 CPU 配额,存在 CPU 节流。

以 8 并发为例:

环境 参数 验证吞吐(次/s) P95 延迟
Windows p=2 35.7 242 ms
Windows p=4 36.3 259 ms
Linux 沙箱 p=2 24.6 649 ms
Linux 沙箱 p=4 23.5 990 ms

在单次 Benchmark 中,p=4 明显加快了完成速度;到了 8 并发,Linux 沙箱上的整体吞吐反而略低,尾延迟则明显恶化。原因需要结合 CPU 配额、调度和资源竞争来看,而不能认定所有 Linux 服务器都会这样。

内存也值得注意:Windows 这两组在 8 并发时采集到的进程峰值工作集约为 1.16 GiB 与 1.34 GiB 。这不是 64 MiB × 8 就能精确推算的数字;Go 的堆分配、内存回收、并行执行和操作系统统计口径都会影响观测值。

一套密码哈希配置在单次测试里表现优秀,并不意味着它适合你的登录并发量。

7. 真正上线前,还要考虑什么?

密码存储不是调用一次 Hash() 就结束。还有几项边界不能省略:

  • Salt 与 Pepper:Salt 应独立随机生成,可以与哈希一起存储;Pepper 是可选的额外秘密,需要与数据库分离保管,不能代替 Salt。
  • 参数升级 :保存算法和成本信息,在旧密码验证成功后逐步 Rehash。easyhash.VerifyAndUpgrade 可以帮助完成验证和重新生成,但写回数据库时仍要考虑并发更新,避免覆盖更晚的密码修改。
  • 旧算法迁移:如果历史系统保存了 MD5 或旧 bcrypt,应明确限定旧记录的验证入口,在成功登录后升级;不要把兼容旧算法当成新系统的默认策略。
  • 资源保护:限制用户可触发的并发验证数量,并对来自存储或外部的成本参数做上限校验。高成本哈希本身也可能放大拒绝服务风险。
  • 认证仍是完整系统:密码强度、TLS、账号限流、多因素认证、泄露密码检查与安全的重置流程,不能由密码哈希算法替代。

结语

回头看那篇学生时期的博客,我最大的修正不是换了一种"更快"或"更慢"的算法,而是换了一个问题:我们到底想让谁付出计算成本?

对用户来说,一次合理耗时的密码验证应该足够自然;对拿到泄露数据库的攻击者来说,每次猜测都应该昂贵。而对于服务端工程师,还要确保这种防护成本不会先把自己的认证服务压垮。

这也是此次四组实验最想说明的事:密码哈希的安全性与工程成本需要一起设计,不能只看 Benchmark 排名。

参考资料

相关推荐
m0_547486661 小时前
《人工智能安全》全套PPT课件2026(复旦大学)
人工智能·安全·人工智能安全
寻林写彡1 小时前
Web 安全三大漏洞靶场作业全整理:PHP 核心函数与防御方案汇总
安全·php
T.O.P_KING1 小时前
Falco vs. Tetragon:云原生运行时安全选型指南
安全·云原生
leizhen198408091 小时前
Vibe Coding:门槛降了,但判断力成了新的门槛(漫画)
人工智能·安全
guohuang2 小时前
让机器人安全失败:点云中断、TF 超时和控制器崩溃怎么测
git·安全·机器人
持敬chijing3 小时前
白盒测试与代码审计-JS 数据逆向
javascript·网络·安全·web安全·网络安全·网络攻击模型·安全威胁分析
FairGuard手游加固14 小时前
手游加固怎么做?SO、IL2CPP、AssetBundle 与运行时保护详解
安全·游戏·unity
迪康妍妍14 小时前
终端管控实操:封堵网盘与IM外发通道的落地思路
运维·网络·安全·架构·安全性测试