38.4 加密 (对称 / 非对称 / TLS / 密钥管理)
一句话
加密事故几乎都不是“算法被破解”,而是模式、随机数、密钥管理用错:对称加密管数据(必须用 AEAD 模式)、非对称管密钥交换与签名、哈希管完整性、KDF 管口令——选错一个,等于把锁挂在门上却把钥匙插在锁里。
四种原语,四种用途
| 原语 | 解决什么 | 典型算法 | 千万别用来 |
|---|---|---|---|
| 对称加密 | 加密大量数据 | AES-256-GCM、ChaCha20-Poly1305 | 分发密钥(对称密钥没法安全地传) |
| 非对称加密 | 密钥交换、数字签名 | RSA-OAEP、ECDH、Ed25519、ECDSA | 加密大文件(慢 2–3 个数量级、有长度限制) |
| 哈希 | 完整性、指纹 | SHA-256、SHA-3 | 存口令(太快,可暴力枚举) |
| KDF / 口令哈希 | 从口令派生密钥 | Argon2id、scrypt、bcrypt、PBKDF2 | 当普通哈希用(参数太低等于没加) |
| MAC | 完整性 + 身份(对称) | HMAC-SHA256 | 当加密用(MAC 不保密) |
🔑 一句话记住:加密 ≠ 保密 + 完整。AES-CBC 只保密、不防篡改;AES-GCM 两者都做。选了 CBC 又要自己加 HMAC,顺序错(先 MAC 后加密 vs 先加密后 MAC)就是另一类漏洞——直接用 AEAD 模式,别自己拼。
代码:ECB 泄露了什么,GCM 挡住了什么
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"io"
)
// ECB:同样的明文块 → 同样的密文块(这就是为什么它不该用于任何真实数据)
func encryptECB(key, plain []byte) []byte {
block, _ := aes.NewCipher(key)
out := make([]byte, len(plain))
for i := 0; i < len(plain); i += aes.BlockSize {
block.Encrypt(out[i:i+aes.BlockSize], plain[i:i+aes.BlockSize])
}
return out
}
// GCM:AEAD,随机 nonce,自带完整性校验
func encryptGCM(key, plain []byte) []byte {
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
io.ReadFull(rand.Reader, nonce)
return gcm.Seal(nonce, nonce, plain, nil)
}
func decryptGCM(key, sealed []byte) ([]byte, error) {
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
n := gcm.NonceSize()
return gcm.Open(nil, sealed[:n], sealed[n:], nil)
}
func dupBlocks(b []byte) int {
seen := map[string]int{}
dups := 0
for i := 0; i+16 <= len(b); i += 16 {
k := string(b[i : i+16])
seen[k]++
if seen[k] > 1 {
dups++
}
}
return dups
}
func main() {
key := []byte("0123456789abcdef0123456789abcdef") // 32 字节 = AES-256
plain := bytes.Repeat([]byte("SALARY=100000; "), 8) // 128 字节,8 个重复块
ecb := encryptECB(key, plain)
gcm1 := encryptGCM(key, plain)
gcm2 := encryptGCM(key, plain)
fmt.Printf("明文 %d 字节(8 个块,内容完全相同)\n\n", len(plain))
fmt.Printf("ECB 密文重复块 %d/8\n", dupBlocks(ecb))
fmt.Printf("GCM 密文重复块 %d/8\n", dupBlocks(gcm1))
fmt.Printf("同一明文加密两次 ECB 相同=%v,GCM 相同=%v\n",
bytes.Equal(ecb, encryptECB(key, plain)), bytes.Equal(gcm1, gcm2))
// 篡改 1 字节
bad := append([]byte{}, gcm1...)
bad[len(bad)-1] ^= 0x01
if _, err := decryptGCM(key, bad); err != nil {
fmt.Printf("\nGCM 被篡改 1 字节 -> 拒绝:%v\n", err)
}
ecbBad := append([]byte{}, ecb...)
ecbBad[0] ^= 0x01
block, _ := aes.NewCipher(key)
dec := make([]byte, len(ecbBad))
for i := 0; i < len(ecbBad); i += aes.BlockSize {
block.Decrypt(dec[i:i+aes.BlockSize], ecbBad[i:i+aes.BlockSize])
}
fmt.Printf("ECB 被篡改 1 字节 -> 解出 %q(没有报错,只有第一个块变了)\n", string(dec[:16]))
}
本机实测(macOS,Apple M1,Go 1.27):
明文 128 字节(8 个块,内容完全相同)
ECB 密文重复块 7/8
GCM 密文重复块 0/8
同一明文加密两次 ECB 相同=true,GCM 相同=false
GCM 被篡改 1 字节 -> 拒绝:cipher: message authentication failed
ECB 被篡改 1 字节 -> 解出 "3R\xfd\xf1\by\x18\x00\x9a(YDXL\x84\xbb"(没有报错,只有第一个块变了)
读数:
| 现象 | 含义 |
|---|---|
| ECB 8 个块里 7 个密文块重复 | 明文里的重复结构原样泄露:看一眼密文就知道“有 8 条相同的薪资记录” |
| GCM 0 个重复块 | 随机 nonce 让相同明文得到不同密文,语义安全 |
| 同一明文两次 ECB 密文相同、GCM 不同 | ECB 是确定性的:可做“密文比对攻击”(猜一个值加密后比对) |
| 篡改 1 字节:GCM 拒绝,ECB 静默解出错误明文 | 没有完整性校验的加密是可延展的(malleable),攻击者可以定向改明文 |
⚠️ 比 ECB 更隐蔽的是 nonce 重用:GCM 用同一个 nonce 加密两条消息,会同时泄露两条明文的关系(异或可恢复),且伪造认证标签成为可能。nonce 必须是每条消息唯一,不是“每次启动唯一”。
信封加密:为什么真实系统不直接用主密钥加密数据
| 方案 | 加密对象 | 轮换时要做什么 | 代价 |
|---|---|---|---|
| 直接用主密钥加密数据 | 全部数据 | 用新主密钥重新加密所有数据 | 100 GB 数据按 100 MB/s 重加密 ≈ 1000 秒,且要考虑在线一致性 |
| 信封加密(KEK + DEK) | 数据用 DEK 加密,DEK 用 KEK 加密 | 只重新加密DEK(每条数据一个 DEK,几百字节) | 秒级完成,数据本身不动 |
🔑 信封加密解决的不是“更安全”,而是**“轮换的成本”**:密钥必须能定期轮换(泄露、合规、人员变动),如果轮换等于全量重加密,团队就会“能拖就拖”,轮换制度形同虚设。把“换密钥”变成“换一个几百字节的密文”,轮换才可能真的执行。
KEK 放哪、怎么生成、怎么轮换、怎么审计访问——这是 13.4 密钥与凭证管理的主场(KMS/Vault 的落地)。本节只强调一条:密钥不能和密文放在一起(同一个库、同一个仓库、同一个配置文件)。令牌签名密钥同理,见 38.2 认证 (Session / JWT / OAuth2 / OIDC)。
TLS 保护什么、不保护什么
| 保护 | 不保护 |
|---|---|
| 传输中 confidentiality(中间人看不到内容) | 端点上的数据(落库、日志、备份)——那是静态加密的事 |
| 传输中 integrity(中间人改不了) | 应用层语义(业务字段被合法客户端改掉) |
| 服务端身份(证书) | 客户端身份(除非 mTLS) |
| 前向保密(ECDHE) | 内部横向流量(如果内部仍是明文 HTTP) |
🔑 常见误区:“我们上了 HTTPS,所以数据安全了”。TLS 只覆盖链路;数据在服务端落盘、进日志、进备份、进数仓的每一段都需要单独判断(38.5 的分级就是为此)。内部服务间明文 HTTP 是零信任要解决的问题(38.8 零信任)。
反模式
| 反模式 | 后果 |
|---|---|
| 用 AES-ECB 加密任何结构化的数据 | 重复结构泄露(本节实测 7/8 块重复) |
| 固定 IV / 固定 nonce | 相同明文相同密文,且 GCM 下可伪造 |
| 用 MD5/SHA1 存口令 | 彩虹表 + GPU 秒级破解;应用 Argon2id/bcrypt |
| 口令哈希参数太低(如 PBKDF2 1000 次) | 单卡每秒可试几百万次 |
| 密钥硬编码在代码/镜像/配置中心明文 | 一次仓库泄露 = 全量数据泄露 |
| 自研加密算法或“XX 混淆加密” | 没有经过密码学分析,通常一层壳 |
| 用 Base64 当加密 | 编码不是加密,任何人都能解 |
| 加密后把密钥存在同一张表 | 脱裤子放屁,等于没加密 |
| 认为“加密了就不用脱敏” | 密钥一旦泄露全部明文;展示层仍要脱敏(38.5) |
自检
Q1 为什么“把 AES 换成 AES-256”不能修复 ECB 的问题?
▸ 答案:因为问题在模式不在密钥长度。ECB 对每个 16 字节块独立加密,相同明文块必然得到相同密文块,与密钥是 128 位还是 256 位无关——换更长的密钥只是让暴力破解更难,泄露的“结构信息”一点没少。修复必须换模式(GCM/ChaCha20-Poly1305)并引入随机 nonce,让相同明文得到不同密文。
Q2 为什么口令不能“先 SHA-256 再加盐”?
▸ 答案:因为 SHA-256 是为速度设计的:现代 GPU 每秒可算上亿次,加盐只防彩虹表、不防暴力枚举(盐是公开的,攻击者对每个候选口令算一次 SHA256(salt||pwd) 即可)。口令需要的是慢哈希:Argon2id/scrypt/bcrypt 通过内存硬(memory-hard)与可调迭代,把单次尝试成本提高到毫秒级,使暴力枚举在经济上不可行。
Q3 信封加密里,如果 DEK 泄露了怎么办?如果 KEK 泄露了呢?
▸ 答案:DEK 泄露只影响一条数据(一条数据一个 DEK),处置是重新生成该 DEK 并重加密这一条;KEK 泄露影响所有 DEK,处置是用新 KEK 重新包裹所有 DEK(只处理密文,不动数据本身),并立即轮换、审计历史访问。这正是分层的目的:把“全量重加密”的爆炸半径缩小到“一个密钥文件”。
Q4 一个团队说“内部服务都在 VPC 内,不用 TLS”。这个判断在什么前提下成立、什么情况下不成立?
▸ 答案:成立的前提是:VPC 内没有多租户共享网络、没有横向移动风险、没有合规要求(如等保/PCI 要求传输加密)、也没有“某个服务被攻破后嗅探内网”的威胁模型。不成立的情况很常见:① 攻击者拿到一个 Pod 就能嗅探同网段流量;② 云厂商的底层网络不是你的信任边界;③ 合规审计要求“全链路加密”。零信任的默认答案是“默认不信任网络”,内部也上 mTLS(38.8 零信任)。