学习目标
复习提示:类比(先建立直觉):给密码"加密"存储,就像把钥匙锁进一个"越撬越费劲"的保险箱。坏人偷到保险箱(数据库泄露),也得花极大代价才能逐个试出钥匙——这正是"慢哈希"的设计哲学:故意让每次验证都又慢又费内存,拖垮暴力破解。
学完本章你应该能够:
- 说清为什么"密码不能用普通哈希(如 MD5/SHA)或对称加密存储",而要用 Argon2id / bcrypt 这类慢哈希 + 加盐算法。
- 用
golang.org/x/crypto/argon2实现Hash与Verify,理解 salt、参数(memory / iterations / parallelism)与编码格式的含义。 - 在验证时用
crypto/subtle.ConstantTimeCompare做恒定时间比较,避免计时侧信道。 - 讲清 bcrypt 仍是可接受的备选场景(兼容存量数据、内存极度受限),以及它和 Argon2id 的取舍。
前置知识:
- Go 基础:
crypto/rand、encoding/base64、strings - 哈希与盐(salt)的基本概念
golang.org/x/crypto包的使用
本章你会动手做的事:
- 跑一遍
Hash("my_password"),观察每次输出的 salt 都不同、但都能Verify通过。 - 故意把
iterations/memory调大,测一下Hash耗时变化,理解"慢"的意义。 - 用 bcrypt 生成一个哈希并
CompareHashAndPassword校验,对比两者 API 风格。
复习提示:这张图在讲什么:注册 / 改密时做"慢哈希 + 随机盐"得到自描述编码串存库;登录验证时从库中取出的编码串解析出参数与盐,对输入重算后用恒定时间比较。
flowchart TD
P[用户密码] -->|随机 salt| H[Argon2id 慢哈希
memory/iter/parallel]
H --> E[编码字符串
$argon2id$v=19$m=...$salt$hash]
E --> DB[(存储到数据库)]
L[登录时输入] --> V[Verify: 解析参数+重算]
DB --> V
V -->|恒定时间比较| R{匹配?}推荐实现:Argon2id
使用官方维护的 golang.org/x/crypto/argon2 包:
package password
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// OWASP 2023 推荐参数(根据服务器性能调整)
const (
memory = 64 * 1024 // 64 MB
iterations = 3
parallelism = 2
saltLength = 16
keyLength = 32
)
func Hash(password string) (string, error) {
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, keyLength)
// 格式: $argon2id$v=19$m=65536,t=3,p=2$<salt>$<hash>
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, memory, iterations, parallelism,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
)
return encoded, nil
}
func Verify(password, encodedHash string) (bool, error) {
// 解析编码字符串提取参数和 salt/hash
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return false, errors.New("invalid hash format")
}
var version int
var memory uint32
var iterations uint32
var parallelism uint8
fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism)
fmt.Sscanf(parts[2], "v=%d", &version)
salt, _ := base64.RawStdEncoding.DecodeString(parts[4])
expectedHash, _ := base64.RawStdEncoding.DecodeString(parts[5])
computed := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(expectedHash)))
return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil
}
复习提示:⚠️ 新手必踩的坑:自己实现"快哈希"存密码。MD5 / SHA-256 计算极快,一旦数据库泄露,攻击者用彩虹表或 GPU 集群几秒就能反查出大量明文密码。密码存储必须用 Argon2id / bcrypt 这类"慢哈希 + 每用户随机盐"的算法,并且盐要随机生成、随密文一起存储,而不是写死在代码里。
bcrypt
如果你的项目需要兼容已有 bcrypt 数据、或部署环境内存极度受限(< 64MB),bcrypt 仍然是可接受的选择:
import "golang.org/x/crypto/bcrypt"
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) // cost=10
ok := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
复习提示:⚠️ 新手必踩的坑:
bcrypt 对密码长度敏感。它只处理前 72 字节,超长部分会被静默截断;同时 cost 越大越安全但也越慢,生产环境一般 cost=10~12,别为了"更安全"无脑拉到很大导致登录接口被拖垮。自测题与动手练习
自测题(合上书能答出来,才算懂):
面试官
为什么不能用 MD5 / SHA-256 直接存密码?加盐(salt)解决了什么问题?
候选人
MD5/SHA 计算极快,库一旦泄露,攻击者用彩虹表或 GPU 集群几秒就能反查大量明文。
盐是每用户随机生成的一段值,混入哈希输入,让相同密码也得到不同密文,使彩虹表失效、只能逐个暴力破解。
盐是每用户随机生成的一段值,混入哈希输入,让相同密码也得到不同密文,使彩虹表失效、只能逐个暴力破解。
面试官
Argon2id 的
memory / iterations / parallelism 三个参数分别在「拖慢破解」上起什么作用?候选人
memory 提高每次哈希占用的内存,拖垮 GPU/ASIC 并行爆破;iterations 增加迭代轮数,直接拉长单次要的时间;parallelism 增加并行度,同样抬高内存/算力门槛。
三者共同把"试一个密码"的成本拉高到不可接受。
三者共同把"试一个密码"的成本拉高到不可接受。
面试官
为什么
Verify 要用 subtle.ConstantTimeCompare 而不是普通的 ==?普通比较可能泄露什么?候选人
普通
攻击者可用计时侧信道逐字节猜出正确哈希。ConstantTimeCompare 无论结果如何都走固定时长,杜绝这种泄露。
== / 字符串比较是"短路“的——第一个字节不对就返回,耗时随前缀正确长度变化。攻击者可用计时侧信道逐字节猜出正确哈希。ConstantTimeCompare 无论结果如何都走固定时长,杜绝这种泄露。
面试官
编码字符串
$argon2id$v=19$m=65536,t=3,p=2$<salt>$<hash> 里每一段分别代表什么?为什么连参数一起存?候选人
把参数随密文一起存,Verify 时解析即可用相同参数重算,无需额外配置、也不会因参数变更导致旧密码无法校验。
$argon2id$ 算法、v=19 版本、m/t/p 是 memory/iterations/parallelism、$<salt>$ 随机盐、$<hash>$ 结果。把参数随密文一起存,Verify 时解析即可用相同参数重算,无需额外配置、也不会因参数变更导致旧密码无法校验。
面试官
什么场景下 bcrypt 仍是可接受的选择?它相比 Argon2id 的主要短板是什么?
候选人
需要兼容存量 bcrypt 数据,或部署环境内存极度受限(< 64MB)时,bcrypt(cost=10)仍合理。
短板:只处理前 72 字节(超长静默截断)、抗 GPU/ASIC 能力弱于 Argon2id,且参数维度没有 Argon2id 丰富。
短板:只处理前 72 字节(超长静默截断)、抗 GPU/ASIC 能力弱于 Argon2id,且参数维度没有 Argon2id 丰富。
动手练习(建议真做一遍):
- 把 OWASP 推荐参数里的
memory调到 256MB、iterations调到 4,测一次Hash耗时,直观感受"慢哈希"的成本。 - 写一个小脚本,批量生成 1000 个相同密码的哈希,确认 salt 随机、密文各不相同(防彩虹表)。
- 用 bcrypt 生成一个哈希,再用一个错误密码
CompareHashAndPassword,确认返回错误而非 panic。
本章小结
复习提示:
- 别用普通哈希:MD5 / SHA 太快且无盐,一旦库泄露极易被彩虹表 / 暴力破解还原。
- 用慢哈希 + 盐:Argon2id 是 OWASP 首推,靠内存 + 迭代 + 并行三维度拉高破解成本;salt 随机生成、随密文一起存储。
- 编码即自描述:
$argon2id$...$salt$hash把算法版本与参数都打包进字符串,Verify时解析后即可复算,免维护额外配置。 - 验证要恒定时间:
subtle.ConstantTimeCompare避免计时侧信道,不让攻击者从耗时差异猜出前缀正确性。 - bcrypt 仍可用:在需要兼容存量数据或内存 < 64MB 的受限环境,bcrypt(cost=10)是合理备选,但抗 GPU/ASIC 能力弱于 Argon2id。
密码存储是安全底座的第一环;下一章我们把这种"防住坏人"的思路延伸到传输层(mTLS)与配置层(Vault)。