哈希、加密 与签名——密码学三件套
第二天的目标是把后面所有协议依赖的"密码学零件"全部认清—— 通用哈希(SHA-256)和密码哈希(bcrypt / argon2)为什么不能混用; 对称(AES)和非对称(RSA / Curve25519)各自的取舍; HMAC 解决了"用哈希做认证"的什么问题; 数字签名又比 HMAC 多解决了什么。 这些零件是 JWT / OAuth / mTLS / API 签名所有协议的物理基础—— 不理解它们,就只能"按文档抄代码",踩到坑也找不到根因。
思维导图
通用哈希函数
哈希函数把任意长度的输入,映射到固定长度的输出——但"映射"二字不重要, 重要的是它需要满足的三个安全性质。 这三个性质既是哈希函数的"凭据",也是它的"局限"—— 理解它们,才能明白为什么 MD5 已被宣判死刑, 也才能明白为什么哪怕是最新的 SHA-3,也不该用来存密码。
三个安全性质
| 性质 | 定义 | 被破意味着什么 |
|---|---|---|
| 原像抗性 Preimage Resistance | 给定 h, 找到 x 使 H(x)=h 在算力上不可行 | 攻击者可从密码哈希反推出密码 |
| 第二原像抗性 Second Preimage Resistance | 给定 x, 找到另一个 x' 使 H(x)=H(x') 不可行 | 攻击者可伪造一个不同的文档但哈希相同 |
| 抗碰撞 Collision Resistance | 任意找到两个 x ≠ x' 使 H(x)=H(x') 不可行 | 攻击者可主动构造两个哈希相同的输入(SHA-1 / MD5 实际事件) |
常见算法对比
| 算法 | 输出长度 | 状态 | 推荐用途 |
|---|---|---|---|
| MD5 | 128 bit | 已破 (2004) | 仅用于非安全场景的"指纹"(如缓存键) |
| SHA-1 | 160 bit | 已破 (2017) | 不要在新项目使用; 旧 Git 在迁移 |
| SHA-256 (SHA-2) | 256 bit | 仍然安全 | 通用首选; HMAC / JWT / TLS 的工作哈希 |
| SHA-3 (Keccak) | 224–512 bit | 安全 · 不同设计 | 需要 SHA-2 之外多样性时备选 |
| BLAKE3 | 可变 | 安全 · 极快 | 大文件 / 高吞吐场景, 比 SHA-256 快 4-10× |
工程用途
完整性校验
下载文件后比对 sha256sum——传输过程任何一 bit 翻转,哈希都对不上。这是哈希最古老的用途。
数据指纹 / 去重
把内容 hash 成 32 字节, 当作缓存键 / 唯一标识。CDN 的 ETag、容器的 image digest、IPFS 的 CID 全是这个套路。
内容寻址
Git 用 SHA-1 / SHA-256 做 commit ID; 每个 commit 都"指向"父 commit 的 hash, 构成不可篡改的链条。
Merkle Tree
把多个 hash 两两组合再 hash, 形成一棵树。区块链、TLS 证书透明度日志(CT)、Git 都在用——可以高效证明"某条记录确实在集合里"。
密码哈希: bcrypt / scrypt / argon2
用 MD5(password) 或者 SHA-256(password) 存数据库,
是后端工程师最常见的"看起来安全实则裸奔"的代码。
问题不在"哈希了没有",而在用错了哈希——
通用哈希为"快"而生,密码哈希必须为"慢"而生。
这一节就是要讲清这个反直觉的设计哲学。
用 SHA-256 存密码的攻击
三个对抗手段
- 加盐 (Salt)。 每个用户一个随机盐, 拼到密码上再哈希。作用: 让"全表彩虹表"失效——攻击者必须对每个用户单独跑字典, 不能复用结果。
- 慢哈希 (Slow Hashing)。 故意做几千~几十万次迭代, 让一次校验耗时 50-500ms。用户登录感觉不到, 攻击者却被拖慢几个数量级。
- 消耗内存 (Memory-Hard)。 算法故意需要大量 RAM(几十~几百 MB), 让 GPU / ASIC 优势失效——GPU 单卡显存只有几十 GB, 同时跑几百路就被卡死。
主流密码哈希算法
| 算法 | 年代 | 慢哈希 | Memory-Hard | 推荐度 |
|---|---|---|---|---|
| PBKDF2 | 2000 (RFC 2898) | ✓ (迭代次数) | ✗ | FIPS 合规场景 · 否则不推荐 |
| bcrypt | 1999 | ✓ (cost 因子) | 部分 (4 KB) | 稳健 · 70 字符密码上限是已知缺陷 |
| scrypt | 2009 (RFC 7914) | ✓ | ✓ | 可选 · 已基本被 argon2 取代 |
| argon2id | 2015 PHC 冠军 | ✓ | ✓ + 抗侧信道 | 现代首选 · OWASP 推荐第一 |
Go 实战: bcrypt 与 argon2id
// password_bcrypt.go · 最简单 — golang.org/x/crypto/bcrypt package main import ( "fmt" "golang.org/x/crypto/bcrypt" ) func main() { pw := []byte("correct horse battery staple") // 1) 哈希存储 — cost=12 是 2024 年合理值 hashed, _ := bcrypt.GenerateFromPassword(pw, 12) fmt.Println("DB:", string(hashed)) // → $2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy // 2) 登录时校验 — 不需要单独取盐, hashed 字符串里自带 err := bcrypt.CompareHashAndPassword(hashed, pw) fmt.Println("OK?", err == nil) }
// password_argon2.go · 更现代 — golang.org/x/crypto/argon2 package main import ( "crypto/rand" "crypto/subtle" "encoding/base64" "fmt" "strings" "golang.org/x/crypto/argon2" ) // OWASP 2024 推荐参数 const ( memKiB = 64 * 1024 // 64 MB iters = 3 parallel = 4 saltLen = 16 keyLen = 32 ) func Hash(pw string) string { salt := make([]byte, saltLen) _, _ = rand.Read(salt) h := argon2.IDKey([]byte(pw), salt, iters, memKiB, parallel, keyLen) // 标准编码格式 (PHC string format): $argon2id$v=19$m=...$salt$hash return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, memKiB, iters, parallel, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(h)) } func Verify(pw, encoded string) bool { parts := strings.Split(encoded, "$") // 简化版: 实际生产请解析参数, 这里假设跟 Hash 一致 salt, _ := base64.RawStdEncoding.DecodeString(parts[4]) expected, _ := base64.RawStdEncoding.DecodeString(parts[5]) actual := argon2.IDKey([]byte(pw), salt, iters, memKiB, parallel, keyLen) return subtle.ConstantTimeCompare(actual, expected) == 1 } func main() { enc := Hash("correct horse battery staple") fmt.Println("DB:", enc) fmt.Println("OK?", Verify("correct horse battery staple", enc)) }
subtle.ConstantTimeCompare 而不是 bytes.Equal——前者耗时与输入相同,后者会有时序侧信道。Day 01 也提到过这一点对称 vs 非对称加密
加密分两大流派,区别只有一句话: 对称用同一把密钥加密和解密——快,但密钥必须事先约定; 非对称用一对公私钥,公钥加密私钥解密——慢,但公钥可以随便发。 现代世界用的是混合加密: 非对称协商一次性的对称密钥,后续通信走对称。TLS 1.3 就是这个套路。
两种加密的对比示意
主流算法对照
| 类型 | 算法 | 密钥长度 | 状态 / 推荐 |
|---|---|---|---|
| 对称 · 分组 | AES-128 / 256 | 128 / 256 bit | 行业标准 · 用 GCM 模式 |
| 对称 · 流 | ChaCha20-Poly1305 | 256 bit | 移动 / 无 AES-NI 场景更快 |
| 对称 · 旧模式 | AES-CBC / ECB | — | 无认证 · 不再推荐裸用 |
| 非对称 · 加密 | RSA-OAEP | 2048 / 3072 bit | 稳健 · 但速度慢且密钥大 |
| 非对称 · 密钥交换 | X25519 / ECDH | 256 bit | TLS 1.3 默认 · 短小快速 |
| 非对称 · 签名 | Ed25519 | 256 bit | 现代首选 · 短小快速安全 |
Go 实战: AES-256-GCM
// aes_gcm.go · 标准库 crypto/aes + crypto/cipher package main import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/hex" "fmt" ) func main() { // 1) 准备 32 字节 (256-bit) 密钥 — 生产请从 KMS / 配置中心取 key := make([]byte, 32) _, _ = rand.Read(key) block, _ := aes.NewCipher(key) gcm, _ := cipher.NewGCM(block) // 2) 12 字节随机 nonce — 同一密钥下绝不能重复 nonce := make([]byte, gcm.NonceSize()) _, _ = rand.Read(nonce) plaintext := []byte("hello, day02") aad := []byte("user_id=42") // 附加认证数据 (不加密但参与认证) // 3) Seal: 密文 = nonce || ciphertext || tag ct := gcm.Seal(nil, nonce, plaintext, aad) fmt.Println("ciphertext:", hex.EncodeToString(ct)) // 4) Open: 任何字节被篡改都会返回 error pt, err := gcm.Open(nil, nonce, ct, aad) if err != nil { fmt.Println("verify failed:", err); return } fmt.Println("plaintext:", string(pt)) }
nonce 不能重用
同一密钥下用同一个 nonce 加密两次, GCM 的安全性整体崩塌——攻击者可以恢复明文异或值。生产中 nonce 必须从 crypto/rand 来,12 字节随机已足。
密钥永远不要硬编码
放进代码就等于推到 Git, 推到 Git 就等于公开。生产请用 KMS(AWS KMS / 阿里云 KMS) 或 Vault, 启动时拉取, 内存中持有。
HMAC 与数字签名
加密保证秘密, 但很多场景我们不需要秘密,只需要完整性 + 来源可证—— "这条消息确实是 Alice 发的,而且没被改"。 解决这件事的两个工具是 HMAC(对称密钥, 双方都能算) 和 数字签名(非对称密钥, 只有私钥持有方能签)。 JWT / Webhook / OAuth 签名 / TLS 证书全部建立在这两个工具之上。
HMAC vs 数字签名 · 对比示意
HMAC: 为什么不是 H(K ‖ msg)?
最朴素的想法: 既然要"用密钥认证消息", 那 SHA256(key + msg) 不就行了?
不行。基于 Merkle–Damgård 结构的哈希函数(SHA-1 / SHA-256)
存在长度扩展攻击: 攻击者拿到 H(key + msg) 和 msg,
不需要知道 key, 就能算出 H(key + msg + suffix)。
这意味着攻击者可以"在合法消息后面追加任意内容并伪造出有效 MAC"。
HMAC(K, m) = H( (K' ⊕ opad) ‖ H( (K' ⊕ ipad) ‖ m ) )。
两次哈希 + 两个固定填充 (ipad/opad) ——保证内部状态不能被外部继续追加。
看起来繁琐, 但只是一次性的设计代价, 用起来跟普通哈希一样简单, 并彻底堵住了长度扩展攻击。
所有"用对称密钥认证消息"的场景, 都该用 HMAC, 不要手搓拼接。Go 实战: HMAC-SHA256
// hmac_sha256.go · 验证 Webhook 签名的标准套路 package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" ) func sign(key, msg []byte) string { mac := hmac.New(sha256.New, key) mac.Write(msg) return hex.EncodeToString(mac.Sum(nil)) } func verify(key, msg []byte, sig string) bool { expected := sign(key, msg) return hmac.Equal([]byte(expected), []byte(sig)) // 常量时间比较 } func main() { key := []byte("my-app-secret") msg := []byte("event=order.paid&order_id=42&ts=1234567890") sig := sign(key, msg) fmt.Println("X-Signature:", sig) fmt.Println("OK?", verify(key, msg, sig)) }
数字签名: 比 HMAC 多解决什么
| 性质 | HMAC | 数字签名 |
|---|---|---|
| 密钥模型 | 对称 · 双方共享 | 非对称 · 签发方有私钥, 验证方有公钥 |
| 完整性 + 来源 | ✓ | ✓ |
| 不可否认性 | ✗ 双方都能产生 MAC | ✓ 只有私钥持有人能签 |
| 多方验证 | 每对人都要分发密钥 | 公钥发布一次, 全网验证 |
| 性能 | 极快 (μs 级) | 慢一些 (ms 级) |
| 典型用途 | Webhook · API 签名 · JWT HS256 | JWT RS256/ES256 · TLS 证书 · 软件包签名 |
现代签名算法
RSA 签名
2048+ bit 密钥; 兼容性最好, 但密钥和签名都大。新项目用 PSS 模式而不是老的 PKCS#1 v1.5。
椭圆曲线 DSA
P-256 (secp256r1) 是主流, JWT 的 ES256 用的就是它。必须用确定性 nonce (RFC 6979),否则烂随机数会泄露私钥(Sony PS3 事件)。
Ed25519 (EdDSA)
RFC 8032 · 密钥 32 字节、签名 64 字节、速度快、API 不容易用错。新项目首选。SSH / Tailscale / Signal 都在用。
Go 实战: Ed25519 签名与验证
// ed25519_sign.go · 标准库 crypto/ed25519, 不需要任何外部依赖 package main import ( "crypto/ed25519" "crypto/rand" "encoding/hex" "fmt" ) func main() { // 1) 生成密钥对 — pub 公开, priv 严格保密 pub, priv, _ := ed25519.GenerateKey(rand.Reader) fmt.Println("public (", len(pub), "B):", hex.EncodeToString(pub)) fmt.Println("private (", len(priv), "B):", hex.EncodeToString(priv)) msg := []byte("hello, day02") // 2) 签名 — 输出 64 字节 sig := ed25519.Sign(priv, msg) fmt.Println("sig (", len(sig), "B):", hex.EncodeToString(sig)) // 3) 验证 — 只需要公钥 ok := ed25519.Verify(pub, msg, sig) fmt.Println("verify?", ok) // 4) 改一个字符再验, 必然失败 ok = ed25519.Verify(pub, []byte("hello, day03"), sig) fmt.Println("tampered verify?", ok) }
常见疑问
Q1 MD5 / SHA-1 都"已破"了, 是不是任何场景都不能再用? +
不必一棍子打死, 但要分场景:
必须避免: 任何"有对手"的安全场景——密码哈希、数字签名、证书、防篡改校验。攻击者可以主动构造碰撞,让"看起来正确的校验"通过假数据。
仍可用: 完全非对抗的场景, 比如缓存键、HashMap 分桶、非恶意来源的下载完整性(只为防网络传输位翻转)。这些场景不存在"主动构造碰撞的攻击者"。
但新项目里没必要再选 MD5—— xxHash / BLAKE3 速度更快, SHA-256 也只是慢一点。"避免使用 MD5"是最便宜的安全收益。
Q2 SHA-256 这么快, 为什么不能用来存密码? 我加盐了也不行? +
"快"对哈希函数本身是优点, 但对密码哈希是致命缺陷。
加盐确实能让彩虹表失效(攻击者不能一次跑出全表), 但加盐不能拖慢单次校验——攻击者拿到泄露的数据库后, 依然可以"对每个用户分别跑"。而 GPU 上 SHA-256 每秒能算几十亿次, 字典攻击照样 5 分钟跑完。
密码哈希需要的不是"加盐这一招", 而是三招齐上: 加盐 + 慢哈希 + memory-hard。bcrypt / argon2 之所以叫"密码哈希函数", 是因为它们故意设计成不可加速——单次校验 100ms, GPU 也救不了攻击者。
所以正确公式: "密码 + 盐 + 慢哈希 + 内存", 不是"密码 + 盐 + 任意哈希"。
Q3 bcrypt 和 argon2 选哪个? 我看 bcrypt 也很常见, 是不是不必折腾? +
OWASP 2024 的推荐顺序: argon2id ≻ scrypt ≻ bcrypt ≻ PBKDF2。但前三个都"足够安全",差别在边际收益。
选 argon2id 的理由: 现代设计、抗 GPU 最强(memory-hard 真做到位)、参数可调更灵活、PHC 比赛的官方冠军。新项目优先选它。
选 bcrypt 的理由: 历史悠久 / 集成最多 / 现有 ORM 多有内置 / 不需要额外依赖。维护已久的项目继续用 bcrypt 完全可以, 不必为换而换。已知缺陷只有一个: 密码 72 字节后被截断——如果你的系统允许超长密码, 要么先 SHA-256 一遍再 bcrypt(标准做法), 要么换 argon2id。
选 PBKDF2 的理由: 几乎只剩一个——必须满足 FIPS 140-2 合规(美国政府场景)。否则它的抗 GPU 性最差, 不该选。
Q4 为什么不能再用 AES-CBC? 我看老系统都在用啊。 +
AES-CBC 本身没有问题, 问题在于"裸用 CBC 没有认证"。攻击者可以翻转密文里的 bit, 让解密结果改变, 而你的应用完全不知道密文被篡改过。
经典攻击是 Padding Oracle: 攻击者通过观察"解密失败 vs 解密成功但业务报错"的差异, 一字节一字节恢复明文。历史上 ASP.NET、Java EE、OpenSSL 都中过。
正确做法有两种: (1) 用 AEAD(AES-GCM / ChaCha20-Poly1305) ——加密的同时认证; (2) "Encrypt-then-MAC" 模式 —— 先 CBC 加密, 再 HMAC 整段。后者实现起来容易出错, 所以现代场景一律推荐前者。
老系统继续用 CBC 没必要立即翻新, 但新写的代码不要用裸 CBC。Go 的 crypto/cipher.NewGCM 跟 NewCBC 用起来一样简单。
Q5 RSA 这么经典, 为什么大家都在推 Ed25519? +
三个核心理由:
(1) 体积。 Ed25519 公钥 32 字节、签名 64 字节; RSA-2048 公钥 ~270 字节、签名 256 字节。在 JWT / HTTP Header / TLS Handshake 这种"每字节都要钱"的场景, 差一个数量级。
(2) 速度。 Ed25519 签名 / 验证比 RSA-2048 快 10-100×, 而且没有"算 RSA 加密慢一些, 算 RSA 签名快一些"这种细分坑。
(3) 难误用。 RSA 的椭圆曲线选错、padding 选错、nonce 选错都能炸; Ed25519 没有 padding 概念, nonce 是确定性派生的, API 几乎没有"用错"的方法。安全 = 默认正确。
那为什么 RSA 还活着? 因为兼容性: 大量旧系统、CA、嵌入式设备只支持 RSA。新建私有协议优先 Ed25519; 必须与外部互联的场景检查对端支持。
复盘问题
- 用一句话分别讲清哈希函数的三个安全性质——原像抗性 / 第二原像抗性 / 抗碰撞。SHA-1 在 2017 年"被破"指的是哪一个性质?
- 为什么
SHA256(key + msg)不是安全的 MAC, 而 HMAC 解决了什么问题? (提示: 长度扩展攻击) - argon2id 的三个关键参数(memory / time / parallelism)分别防什么? OWASP 2024 推荐的值是多少?
- AEAD 是什么, 为什么裸 AES-CBC 不是 AEAD? Padding Oracle 攻击为什么能恢复明文?
- 动手写一段 Go 代码: 用 argon2id 哈希一个密码, 然后写一个
Verify(pw, encoded string) bool函数, 必须用常量时间比较。
今日检查清单
- 能讲清通用哈希(SHA-256)和密码哈希(argon2)的本质差异——"快"与"慢"
- 能列出 bcrypt / scrypt / argon2 各自的设计动机和取舍
- 能解释 HMAC 为什么比
H(key ‖ msg)安全 - 能说出 HMAC 与数字签名的核心差别——是否支持不可否认
- 跑通 Lab: bcrypt 或 argon2id 的密码哈希 + 校验
- 跑通 Lab: AES-256-GCM 加密一段文本, 篡改密文后能看到 Open 返回 error
- 跑通 Lab: Ed25519 签名 + 验证, 篡改消息后验证失败
- 读完 OWASP Password Storage Cheat Sheet 和 RFC 2104 (HMAC, 11 页)
推荐阅读
Password Storage Cheat Sheet
OWASP 出品, 2024 最新版。一页就讲完"密码该怎么哈希、参数怎么定"——直接能复制粘贴的工程结论。
RFC 2104 · HMAC
HMAC 的原始规范, 11 页读完。重点看为什么需要 ipad / opad 两层填充——理解后再也不会想"自己拼 hash"。
RFC 9106 · Argon2
argon2 三个变种的正式规范。重点看 §4 "Recommended Parameters", 一切疑问到这里找答案。
RFC 8032 · EdDSA / Ed25519
Ed25519 的规范文档。30 多页, 但前 10 页足以理解为什么这个算法"难误用"。后面是公式和参考实现。
Serious Cryptography (Aumasson)
2017 No Starch 出版, 公认现代密码学的最佳入门书。没有公式恐惧症的话, 完整啃完一遍, 你就能成为团队的"密码学顾问"。
Day 03 预告
PKI 与 TLS / HTTPS
有了"对称 / 非对称 / 哈希 / 签名"四件套, 第三天把它们组合起来—— 看 CA 怎么用签名给一张证书背书, 看 TLS 1.3 怎么用非对称协商出对称密钥, 然后所有应用数据走AEAD加密。 之后再看 mTLS——服务间互验证书的现代做法。 看完 Day 03, 你应该能用 openssl 自己搭一套 CA + 服务端证书 + 客户端证书, 跑通 mTLS。