《Go 语言编程实战》17.2 传输与存储加密

加密的目标是「数据即使被截获或拖库也读不出」。本节把 TaskHub 的传输与存储两条链路都落成可运行代码:用真实握手演示强制 TLS 1.3 如何拒绝只支持 1.2 的客户端,用 AES-GCM 把 32 字节明文加密成 60 字节并验证篡改必然失败,用 bcrypt 演示同口令两次哈希不同、校验正确。

17.2 传输与存储加密

上一节防的是「应用被注入」,这一节防的是更底层的假设:假设网络是透明的、假设磁盘会被拖走。只要这两条假设不成立,明文传输和明文存储就等于把数据直接交出去。加密要做的事很简单:让截获者与入侵者拿到字节也读不出内容。

本节把 TaskHub 推进到「传输强制 TLS 1.3、敏感字段静态加密、口令只存哈希」:三段实验都在本机真跑——TLS 握手与拒绝、AES-GCM 加解密与篡改检测、bcrypt 哈希与校验,真实输出逐一贴出。

17.2.1 威胁模型与加密边界

先明确「防谁」,否则会陷入「到处加密但都没用」的境地。TaskHub 面对的三类威胁:

威胁场景对应防线
网络窃听中间人读明文流量传输加密(TLS)
存储泄露磁盘/备份/快照被拖走静态加密(AES-GCM)
内部越权运维能看库但不应看明文字段级加密 + 密钥分离

加密不是「加了就安全」,而是密钥管理的问题:数据加密的强度,最终等于密钥的保护强度。如果密钥和应用在同一台机器、同一个配置文件里,磁盘被拖走时密钥也一起没了。因此一个基本原则是密钥与密文分离:密文在数据库,密钥在 KMS(密钥管理服务)或环境注入的独立密钥,两者不放在同一处。

还有一个常被忽略的边界:加密只保护「静态」与「传输中」的数据,不保护「使用中」的数据。应用解密后,明文就在内存里;一个能读进程内存或注入代码的攻击者,照样能拿到明文。所以加密是纵深防御的一层,不是万能药——它和上一节的输入校验、下一节的审计是互补而非替代关系。

17.2.2 传输加密:强制 TLS 1.3

TLS 1.3(RFC 8446)相比 1.2 移除了大量历史包袱:废弃了 RSA 密钥交换、CBC 模式、重协商,握手更快且默认前向保密。生产服务应当强制最低版本,拒绝老协议。用一段本机可运行的程序验证「强制」确实生效——服务端设 MinVersion: tls.VersionTLS13,再用两个客户端分别连接:

srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "ok")
}))
srv.TLS = &tls.Config{MinVersion: tls.VersionTLS13}
srv.StartTLS()
defer srv.Close()

// 客户端 1:允许 1.3,应握手成功
resp, err := srv.Client().Get(srv.URL)

本机真实输出:

客户端1 协商版本=TLS 1.3 密码套件=TLS_AES_128_GCM_SHA256
客户端2(封顶TLS1.2) err: Get "https://127.0.0.1:55483": remote error: tls: protocol version not supported

两行合起来说明「强制」是真的:允许 1.3 的客户端协商到了 TLS 1.3,密码套件是 TLS_AES_128_GCM_SHA256(TLS 1.3 的套件都是 AEAD,没有 CBC);而把客户端封顶在 1.2 时,服务端直接拒绝,握手报 protocol version not supported。服务端日志也印证了这一点:

http: TLS handshake error from 127.0.0.1:55409: tls: client offered only unsupported versions: [303]

[303] 就是 TLS 1.2 的版本号 0x0303。这意味着任何只支持 1.2 的旧客户端都会被挡在门外——这正是你要的显式拒绝,而不是悄悄降级。

生产配置里还应加上:CurvePreferences 只留 X25519 等现代曲线、CipherSuites 交给 Go 默认(TLS 1.3 的套件不可配置,Go 已选安全集),以及用 crypto/tls 的 GetCertificate 做证书热加载。

把这段配置接到真实的 http.Server 上,一个生产可用的骨架:

srv := &http.Server{
	Addr: ":8443",
	Handler: mux,
	TLSConfig: &tls.Config{
		MinVersion:   tls.VersionTLS13,
		CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
		// 证书热加载:轮转证书无需重启
		GetCertificate: certManager.GetCertificate,
	},
}
srv.ListenAndServeTLS("", "") // 证书由 GetCertificate 提供

注意 GetCertificate 返回的是当前有效证书,证书管理器在后台轮转文件并原子替换——这样证书到期前自动换新,服务不重启。本段 ListenAndServeTLS 未在本机实跑(无真实证书链),但 MinVersion 的强制语义已由上一段 httptest 实验验证。

17.2.3 证书与 mTLS

服务端证书解决「客户端如何确认连的是真服务」。几个要点:

  • 绝不用 InsecureSkipVerify: true:它在生产里等于关掉证书校验,让中间人攻击成立。它只应出现在本地测试。
  • 证书要能自动续期:用 autocert(Let’s Encrypt)或由证书管理器轮转,手工换证书迟早会过期。
  • 服务间通信可用 mTLS:不仅服务端出示证书,客户端也出示,双方互验身份。它在零信任网络里是标配。

证书校验失败的典型报错是 x509: certificate signed by unknown authority 或 x509: certificate has expired——后者是运维最不愿见到的「半夜过期」,所以续期必须自动化。

17.2.4 存储加密:AES-GCM

对称加密里,AES-GCM 是首选:它同时提供保密性与完整性(AEAD),篡改密文会导致解密失败,而不是解出一段垃圾。TaskHub 里需要加密的字段包括第三方 API key、webhook 密钥等。本机可运行的最小实现:

key := sha256.Sum256([]byte("kms-managed-master-key")) // 实际应从 KMS 取
plaintext := []byte("tenant=t1;api_key=sk-live-abc123")

block, _ := aes.NewCipher(key[:])
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
rand.Read(nonce) // 每条记录一个随机 nonce
ct := gcm.Seal(nonce, nonce, plaintext, nil)

本机真实输出:

明文 32 字节 -> 密文 60 字节 (nonce 12 + tag 16)
篡改密文后解密: cipher: message authentication failed

解读:32 字节明文变成 60 字节密文,多出的 28 字节 = 12 字节 nonce + 16 字节认证标签(tag)。把密文最后一个字节翻转一位后解密,返回 message authentication failed——GCM 的完整性校验拦住了篡改,攻击者无法在不被发现的情况下改动密文。

三个必须遵守的纪律:

  • nonce 绝不复用:同一个 key 下,nonce 重复会灾难性地泄露明文(GCM 的已知弱点)。随机 12 字节 nonce 在单 key 加密量不大时安全;量极大时应改用计数器 nonce。
  • tag 要一起存:gcm.Seal 已把 tag 附在密文尾部,存库时整个 ct 一起存,解密时整个取回。
  • 密钥不落库:key 来自 KMS 或环境注入,数据库里只有密文。

把加解密封装成一对函数,存库时加密、读库时解密:

func Encrypt(gcm cipher.AEAD, plaintext []byte) ([]byte, error) {
	nonce := make([]byte, gcm.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}
	return gcm.Seal(nonce, nonce, plaintext, nil), nil
}

func Decrypt(gcm cipher.AEAD, ct []byte) ([]byte, error) {
	if len(ct) < gcm.NonceSize() {
		return nil, errors.New("ciphertext too short")
	}
	nonce, body := ct[:gcm.NonceSize()], ct[gcm.NonceSize():]
	return gcm.Open(nil, nonce, body, nil)
}

注意 Seal 的第一个参数传 nonce 作为输出缓冲的前缀——这是一个 Go 惯用法:把 nonce 和密文拼在一个 []byte 里,存库时只存一列。解密时再按 NonceSize() 切开。任何长度检查都不能省:密文比 nonce 还短时直接拒绝,避免 Open 收到非法输入。

17.2.5 口令哈希:bcrypt

口令永远不能加密存储,只能哈希存储——加密可逆,一旦密钥泄露口令全暴露;哈希不可逆。但普通哈希(如 SHA-256)也不行:它太快,攻击者能每秒试几十亿次。口令需要慢哈希。Go 官方推荐 golang.org/x/crypto/bcrypt:

pw := []byte("correct horse battery staple")
h1, _ := bcrypt.GenerateFromPassword(pw, bcrypt.DefaultCost)
h2, _ := bcrypt.GenerateFromPassword(pw, bcrypt.DefaultCost)

本机真实输出:

token(hex) len=64  8bfa9549acf30f85...
bcrypt cost=10 hash1=$2a$10$tkqyhK05DR1uR
bcrypt 同口令两次哈希是否相同: false
校验正确口令: true
校验错误口令: false

四个关键点:

  • cost=10:bcrypt 的默认代价因子,每次哈希约需几毫秒。它让暴力破解的每一次尝试都变贵。
  • 同口令两次哈希不同(false):因为 bcrypt 每次生成随机盐并把它编进哈希串里($2a$10$... 前缀)。盐不需要单独存。
  • 校验用 CompareHashAndPassword:它把哈希串里的盐取出来、对输入口令重算再比对,而不是比较两个哈希串。
  • 第一行还展示了 token 生成:crypto/rand 生成 32 字节随机 token,十六进制后是 64 字符。会话 token、API key 必须用 crypto/rand,绝不能用 math/rand——后者的输出是可预测的,攻击者能算出下一个 token。

口令哈希还有一条工程纪律:登录失败信息不能泄露「用户名是否存在」。无论用户名不存在还是口令错误,都返回同一句「用户名或口令错误」,避免账号枚举。

几种口令算法的取舍:

算法类型抗 GPU 程度内存硬建议
SHA-256快哈希极差否绝不用于口令
bcrypt慢哈希好否通用首选,Go 生态成熟
scrypt内存硬好是需要抗 ASIC 时
argon2id内存硬最好是新系统首选,参数需调

bcrypt 的一个历史限制是只取前 72 字节:超过 72 字节的口令部分会被忽略。这通常无害,但如果你打算支持超长口令(如 passphrase),要么先做一次 SHA-256 预处理再喂给 bcrypt,要么直接换 argon2id。TaskHub 的选择是 bcrypt + 输入侧限制口令长度上限(如 128 字节),并在超长时明确报错而不是静默截断。

17.2.6 密钥管理与信封加密

直接用一个主密钥加密所有数据有个问题:轮换密钥时得把所有数据重新加密一遍。**信封加密(envelope encryption)**解决了这一点:

1. KMS 里有一个主密钥(CMK),永不离开 KMS
2. 为每份数据(或每个租户)生成一个数据密钥(DEK)
3. 用 DEK 加密数据,用 CMK 加密 DEK(得到「被包裹的 DEK」)
4. 存储:密文 + 被包裹的 DEK
5. 解密:先让 KMS 用 CMK 解出 DEK,再用 DEK 解数据

好处是轮换只需换 CMK:旧的被包裹 DEK 用旧 CMK 解,新的用新 CMK,数据本身不用重加密。这也是云厂商 KMS 的标准用法。本地开发时可以用一个「假 KMS」(环境变量注入的密钥)实现同一套接口,生产再切到真 KMS。

17.2.7 加密自检清单

  • 传输强制 MinVersion: tls.VersionTLS13,显式拒绝老协议
  • 生产禁用 InsecureSkipVerify,证书自动续期
  • 服务间通信评估是否需要 mTLS
  • 敏感字段用 AES-GCM,每条记录独立随机 nonce
  • 密钥来自 KMS/环境注入,与密文分离,不进代码仓库
  • 口令用 bcrypt(或 argon2),不用可逆加密、不用快哈希
  • 会话 token / API key 用 crypto/rand,不用 math/rand
  • 登录失败信息不泄露账号是否存在

17.2.8 常见误区

  • 用可逆加密存口令:密钥一泄露,全部口令明文暴露。口令只能哈希。
  • 用 SHA-256 哈希口令:太快,GPU 每秒能试几十亿次。
  • nonce 复用:同一个 key 下重复 nonce 会灾难性泄露明文。
  • 密钥和密文放一起:磁盘被拖走时两者一起丢,等于没加密。
  • InsecureSkipVerify: true 上生产:等于关掉证书校验。
  • 用 math/rand 生成 token:输出可预测,token 可被推算。
  • 只加密传输不加密存储:内网被渗透或备份泄露时明文裸奔。

小结

  • 加密的强度最终等于密钥管理的强度;密钥与密文必须分离。
  • 传输加密应强制 TLS 1.3;本机真实握手显示允许 1.3 的客户端协商到 TLS 1.3,封顶 1.2 的客户端被拒(protocol version not supported)。
  • AES-GCM 同时提供保密性与完整性:本机实测 32 字节明文 → 60 字节密文(12 nonce + 16 tag),篡改一字节即 message authentication failed。
  • 同一 key 下 nonce 绝不复用;tag 随密文一起存储;密钥不落库。
  • 口令只存 bcrypt 慢哈希;本机实测 cost=10、同口令两次哈希不同(随机盐)、校验正确与错误分别返回 true/false。
  • token 用 crypto/rand,math/rand 的输出可预测,绝不可用于安全用途。
  • 大规模场景用信封加密,轮换只需换主密钥,不必重加密全部数据。
  • 加密只保护静态与传输中数据,不保护使用中数据;它是纵深防御的一层,不是万能药。

传输与存储都加密了,还差最后一环:谁在什么时候访问了什么。17.3 审计与合规 会给出防篡改的哈希链审计日志,并用真实实验演示篡改如何被发现。

阅读导航:上一节:17.1 输入校验与注入防护 · 下一节:17.3 审计与合规 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练