学习目标
类比(先建立直觉):给密码"加密"存储,就像把钥匙锁进一个"越撬越费劲"的保险箱。坏人偷到保险箱(数据库泄露),也得花极大代价才能逐个试出钥匙——这正是"慢哈希"的设计哲学:故意让每次验证都又慢又费内存,拖垮暴力破解。
学完本章你应该能够:
- 说清为什么"密码不能用普通哈希(如 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)解决了什么问题?
- Argon2id 的
memory/iterations/parallelism三个参数分别在"拖慢破解"上起什么作用? - 为什么
Verify要用subtle.ConstantTimeCompare而不是普通的==?普通比较可能泄露什么? - 编码字符串
$argon2id$v=19$m=65536,t=3,p=2$<salt>$<hash>里每一段分别代表什么?为什么连参数一起存? - 什么场景下 bcrypt 仍是可接受的选择?它相比 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)。