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 零信任)。

关联