信任链 · TLS 1.3 与 mTLS
Day 02 我们集齐了"哈希 · 加密 · 签名"四件零件。 Day 03 把这四件零件组合成 Web 上每秒成千上万次发生的事——HTTPS 握手。 要点不止于"装个证书": CA 怎么用签名给一张证书背书、 TLS 1.3 凭什么 1-RTT 就能跑完、 SNI / ALPN 给现代 Web 解决了什么、 最后是开放平台服务间认证的核心—— mTLS 双向认证怎么从零搭起来。
思维导图
PKI 与 X.509 证书
"公钥加密"解决的是怎么加密的问题, 但留下一个更难的问题: 这把公钥真的是 api.example.com 的吗? PKI(公钥基础设施)就是为了回答这个问题—— 引入一个被多方信任的第三方 (CA), 由它用数字签名给"公钥 + 主体"绑死, 签出来的东西就叫证书。
证书链与信任锚
X.509 v3 关键字段
| 字段 | 说明 | 典型值 |
|---|---|---|
| Subject | 证书持有者 | CN=api.example.com |
| Issuer | 颁发者(签名方) | CN=DigiCert TLS ... CA1 |
| Subject Public Key Info | 持有者公钥 | RSA 2048 / EC P-256 |
| Validity | 有效期 | notBefore / notAfter |
| Subject Alt Name (SAN) | 实际生效的域名/IP · CN 已废 | DNS:api.example.com, DNS:*.example.com |
| Key Usage | 密钥可以做什么 | digitalSignature, keyEncipherment |
| Extended Key Usage | 更细粒度用途 | serverAuth · clientAuth |
| Basic Constraints | 是否是 CA · 链最大深度 | CA:TRUE · pathLenConstraint:0 |
| CRL / AIA | 吊销列表 / 颁发者 URL | http://crl.digicert.com/... |
| Signature | 颁发者用其私钥对证书内容的签名 | sha256WithRSAEncryption |
PEM 与 DER 编码
二进制编码
Distinguished Encoding Rules · ASN.1 的紧凑二进制形式。最小, 适合传输/存储。文件扩展名 .cer / .der。
Base64 + 头尾包装
Privacy-Enhanced Mail · 把 DER 用 base64 包起来, 加上 -----BEGIN CERTIFICATE----- 头尾。常见扩展名 .pem / .crt。最常用——纯文本能复制粘贴, 多个证书可以拼成一个文件(证书链)。
# 看一张正式证书 (用 https://www.google.com 演示) $ echo | openssl s_client -connect www.google.com:443 -servername www.google.com 2>/dev/null \ | openssl x509 -noout -issuer -subject -dates -ext subjectAltName issuer=C=US, O=Google Trust Services, CN=WR2 subject=CN=www.google.com notBefore=Apr 15 ... 2026 GMT notAfter=Jul 8 ... 2026 GMT X509v3 Subject Alternative Name: DNS:www.google.com # DER ↔ PEM 互转 $ openssl x509 -in cert.pem -outform DER -out cert.der $ openssl x509 -in cert.der -inform DER -out cert.pem # 把证书链拼起来 (服务端要发送 leaf + intermediate, root 在客户端本地) $ cat leaf.pem intermediate.pem > fullchain.pem
吊销机制: CRL / OCSP / Stapling
证书吊销列表
CA 周期性发布"被吊销的证书序列号清单"。问题: 客户端要下载几 MB 文件, 而且更新有延迟(几小时~几天)。
在线吊销查询
客户端遇到证书时, 实时向 OCSP Responder 问"这张还有效吗?"。问题: 增加一次 RTT, 还泄露用户访问行为。
OCSP Stapling
服务端预先向 OCSP 拿一个"有效证明", 握手时直接塞给客户端——零额外 RTT, 不泄露用户行为。现代部署的标准做法。
TLS 1.3 握手
TLS 1.3 (RFC 8446, 2018) 是 TLS 协议历史上最大的一次重构—— 砍掉了 90% 的密码套件, 把握手从 2-RTT 压到 1-RTT, 默认强制前向保密。 理解 1.3 握手, 你就能在排查 TLS 问题时, 用 Wireshark 的输出直接对上协议字段。
TLS 1.2 vs TLS 1.3 握手对比
TLS 1.3 的五个关键改动
- 密码套件大瘦身。 TLS 1.2 有 300+ 套件, TLS 1.3 只保留 5 个 AEAD 套件(AES-GCM / ChaCha20-Poly1305 系列)。历史性弱算法(RC4 / MD5 / SHA1 / CBC) 全部砍掉。
- 1-RTT 默认。 Client 在 ClientHello 就发 KeyShare(基于 ECDHE 的临时公钥), Server 一次回应就能算出共享密钥, 不再需要"先协商再交换"。
- 强制前向保密(Forward Secrecy)。 密钥协商必须用 (EC)DHE, 静态 RSA 密钥交换被废弃——意味着就算服务端私钥日后泄露, 历史流量也解不开。
- 更早加密。 ServerHello 之后所有消息(包括证书)都用握手密钥加密——证书不再明文暴露在网络上。
- 0-RTT 可选。 客户端如果之前连过同一服务器, 可以在 ClientHello 里"附带"应用数据(early_data), 0 个 RTT 就送出去。代价: early_data 可被重放, 只能用于幂等操作。
crypto/tls 在 1.21 起支持 0-RTT, 但默认关闭, 需要显式 opt-in。密钥派生与 HKDF
TLS 1.3 用 HKDF (RFC 5869) 从一个共享秘密派生出多个用途的密钥—— 不同方向(C→S / S→C)、不同阶段(握手 / 应用 / 重启)都有独立密钥。 这就是"前向保密"的工程实现: 每次会话密钥独立, 一个泄露不影响其他。
# 用 Wireshark 看 TLS 1.3 握手 # 1) 在 curl 之前设置 SSLKEYLOGFILE 让 TLS 库导出会话密钥 $ export SSLKEYLOGFILE=/tmp/tls.keys $ curl -v https://www.google.com # 2) Wireshark → Preferences → Protocols → TLS → (Pre)-Master-Secret log filename # 指向 /tmp/tls.keys, 然后捕获包就能看到解密后的握手细节 # curl 直接观察协商出的版本和套件 $ curl -v https://example.com 2>&1 | grep "SSL connection" * SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / RSASSA-PSS
SNI · ALPN · ECH
TLS 自身定义了"怎么协商加密通道", 但现代 Web 需要在握手阶段就回答两个问题—— "我要的是哪个网站?"(IPv4 短缺导致一 IP 多域) 和 "我要走的是哪个上层协议?"(HTTP/1.1 / HTTP/2 / HTTP/3)。 SNI 解决前者, ALPN 解决后者, ECH 解决 SNI 暴露域名的隐私问题。
Server Name Indication
ClientHello 里明文带上"我要访问 api.example.com", 服务端据此选择对应证书。没有 SNI 之前, 一个 IP 只能配一张证书; 有了 SNI, 一台 Nginx 可以为 10000 个域名各自配置不同证书。
Application-Layer Protocol Negotiation
客户端在 ClientHello 列出"我会说: h2, http/1.1, h3", 服务端选一个回复。没有 ALPN 就没有 HTTP/2—— 因为 H2 跑在 TLS 上, 必须在握手时就决定走 H2 还是 H1。
ECH · Encrypted Client Hello
把 SNI 这一段也加密——解决"运营商 / 防火墙仍能通过明文 SNI 看到你访问哪个网站"的隐私问题。 Cloudflare / Firefox 已默认开启, 是 TLS 未来几年最重要的演进。
配套基础设施
| 机制 | 解决问题 | 典型部署 |
|---|---|---|
| HSTS | 强制浏览器只用 HTTPS, 防降级 | 响应头 Strict-Transport-Security: max-age=31536000; includeSubDomains; preload |
| CT (Certificate Transparency) | 所有签发的证书必须公开记录, 防 CA 偷签 | RFC 9162 · 浏览器要求叶子证书包含 SCT |
| CAA DNS 记录 | 声明"只允许 LE / DigiCert 签我的证书" | DNS: example.com. CAA 0 issue "letsencrypt.org" |
| HPKP | 已废弃 · 太容易锁死自己 | — |
mTLS 双向认证 · Go 实战
标准 TLS 只验证"服务端是不是真的", 客户端可以匿名; mTLS(mutual TLS)要求双向: 客户端也要拿出一张被服务端信任的 CA 签的证书。 这是开放平台服务间通信的标准方案—— 内部 Service A 调 Service B, 不靠 Token, 靠证书证明"我是 A"。 Istio / Linkerd 这类 Service Mesh 给每个 Pod 自动签发证书, 默认就是 mTLS。
mTLS 双向握手
Lab · 用 openssl 建本地 CA + 签证书
# 1) 建 CA — 一次性, 私钥严格保管 $ openssl genrsa -out ca.key 4096 $ openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \ -out ca.crt \ -subj "/CN=My Local CA/O=Demo" # 2) 签服务端证书 (注意 SAN 必须写) $ openssl genrsa -out server.key 2048 $ openssl req -new -key server.key -out server.csr \ -subj "/CN=localhost" \ -addext "subjectAltName = DNS:localhost,IP:127.0.0.1" $ openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -out server.crt -days 365 -sha256 \ -extfile <(echo "subjectAltName=DNS:localhost,IP:127.0.0.1") # 3) 签客户端证书 (mTLS 才需要) $ openssl genrsa -out client.key 2048 $ openssl req -new -key client.key -out client.csr \ -subj "/CN=service-a" $ openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -out client.crt -days 365 -sha256
Lab · Go HTTPS 服务端
// https_server.go · 起一个 HTTPS 服务 package main import ( "fmt" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "hello, TLS %x\n", r.TLS.Version) }) // ListenAndServeTLS 自动选 TLS 1.3 + 强密码套件 err := http.ListenAndServeTLS(":8443", "server.crt", "server.key", nil) if err != nil { fmt.Println(err) } }
# 启动 + 用 curl 测试 (--cacert 告诉 curl 信任我们的本地 CA) $ go run https_server.go & $ curl --cacert ca.crt https://localhost:8443/ hello, TLS 304 # 0x0304 = TLS 1.3 # 不指定 --cacert 会报 self-signed certificate $ curl https://localhost:8443/ curl: (60) SSL certificate problem: unable to get local issuer certificate
Lab · Go mTLS 服务端 + 客户端
// mtls_server.go · 要求客户端必须出示证书 package main import ( "crypto/tls" "crypto/x509" "fmt" "net/http" "os" ) func main() { // 1) 把 CA 加进信任池, 用来验证客户端证书 caPEM, _ := os.ReadFile("ca.crt") pool := x509.NewCertPool() pool.AppendCertsFromPEM(caPEM) cfg := &tls.Config{ ClientCAs: pool, ClientAuth: tls.RequireAndVerifyClientCert, // 关键: 强制双向 MinVersion: tls.VersionTLS13, } srv := &http.Server{ Addr: ":8443", TLSConfig: cfg, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 从客户端证书提取身份 cn := r.TLS.PeerCertificates[0].Subject.CommonName fmt.Fprintf(w, "hi, %s\n", cn) }), } _ = srv.ListenAndServeTLS("server.crt", "server.key") }
// mtls_client.go · 带客户端证书 package main import ( "crypto/tls" "crypto/x509" "fmt" "io" "net/http" "os" ) func main() { cert, _ := tls.LoadX509KeyPair("client.crt", "client.key") caPEM, _ := os.ReadFile("ca.crt") pool := x509.NewCertPool() pool.AppendCertsFromPEM(caPEM) cli := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ Certificates: []tls.Certificate{cert}, // 提供自己的证书 RootCAs: pool, // 信任本地 CA MinVersion: tls.VersionTLS13, }, }, } resp, _ := cli.Get("https://localhost:8443/") body, _ := io.ReadAll(resp.Body) fmt.Println(string(body)) // → hi, service-a }
tls.RequireAndVerifyClientCert
服务端的强制开关。还有 VerifyClientCertIfGiven (可选), RequestClientCert (要但不验)——生产 mTLS 一律用 Require。
r.TLS.PeerCertificates[0]
从 HTTP Request 拿到客户端证书。这是"用证书认证身份"的核心——把 Subject.CommonName 或 URIs 当作 user identity。
常见疑问
Q1 TLS 1.2 已经很成熟了, 一定要升 TLS 1.3 吗? +
能升就升。三个原因:
(1) 性能: 握手从 2-RTT 到 1-RTT, 跨国连接节省 100-200ms 的首字节时间。对 API 调用来说是肉眼可见的差距。
(2) 安全默认: TLS 1.2 还能协商出弱密码套件(CBC / RSA 静态密钥交换), 配置稍有失误就开洞。TLS 1.3 全部默认就是强 AEAD + 前向保密, "没法配错"。
(3) 未来已来: HTTP/3 必须用 TLS 1.3 (QUIC 内置), 现在不升, HTTP/3 也用不上。
例外: 极老的客户端(IE11 / Java 7 / 老 Android)只支持 TLS 1.2。生产部署一般同时启用 1.2 + 1.3, 让客户端选最高支持的。禁掉 TLS 1.0 / 1.1是必须的。
Q2 自签证书和 Let's Encrypt 的证书, 在加密强度上有差别吗? +
加密强度完全一样。都是 RSA 2048 / ECC P-256, 都是 TLS 1.3 握手, 协商出的会话密钥强度毫无差别。
差别只有一个: 信任根。Let's Encrypt 的根 CA 被预装在所有操作系统 / 浏览器里, 所以任何客户端都默认信任它签的证书; 自签 CA 没人预装, 必须把 CA 证书手动添加到客户端的信任列表(或用 --cacert)。
所以选择标准: 对公网用户的服务必须用公共 CA(LE / DigiCert), 否则浏览器红屏; 内部服务 / 服务间 mTLS用自签 CA 完全合理, 还能避免暴露内网域名给 CT 日志。
Q3 Let's Encrypt 的证书只有 90 天, 为什么是"短"是好事? +
反直觉但确实是好事——核心理由是降低撤销机制的依赖:
(1) 证书泄露的"自然过期"窗口短: 私钥被偷, 最多 90 天就自动失效, 攻击者能利用的时间窗口很小。一年期证书泄露的"未来损失"是 90 天的 4 倍。
(2) 逼迫自动化: 90 天意味着不能手动续签, 必须用 certbot / cert-manager 这种自动化工具。"自动化"是安全的副产品——配错了立刻就会暴露, 而不是一年后某个深夜过期。
(3) 淘汰过时配置: 算法 / 密钥长度 / 链结构每隔几个月就刷新一次, 历史包袱不容易堆积。
2024 年 Apple 提议要把证书最长有效期降到 47 天, 行业方向是"越来越短"。如果你还在手动续签 1 年证书, 是时候把它自动化了。
Q4 mTLS 和 OAuth 都能做"服务间认证", 各自什么时候用? +
两个机制定位不同——mTLS 解决"对端是谁"(Workload Identity), OAuth 解决"代谁做事"(User Authorization)。
mTLS 适合: 服务对服务的身份认证。Service A 拿 mTLS 证明"我是 cluster 里那个 A Pod", Service B 据此决定要不要给数据。证书由 PKI / SPIFFE 统一签发, 无需每次拿 token。
OAuth 适合: 应用代用户调 API。第三方 App 拿 OAuth Token 证明"用户 Alice 同意了我调取她的联系人", token 里带着 scope 和用户信息。
组合用法(典型开放平台): 服务网格内部走 mTLS, 边界网关验证 OAuth Token 后, 加上 mTLS 把请求转发到内部服务——两层叠加, mTLS 守住身份, OAuth 守住授权。
Q5 证书过期了会发生什么? 怎么避免凌晨被叫起来续签? +
过期当天所有 HTTPS 请求全部 502 / SSL error——浏览器拒绝连接, API 调用方报 x509: certificate has expired。这是过去 10 年最常见的"全公司停服"原因之一。
三层防御:
(1) 自动化续签: 公网证书用 certbot / acme.sh; Kubernetes 集群用 cert-manager; 云上用 ACM / 阿里云证书服务自动续。从签发那刻起就别让人参与。
(2) 监控提前 30 天告警: Prometheus 的 blackbox_exporter / SSL Labs API / 自己写脚本 openssl x509 -enddate 都行。等过期再告警就晚了。
(3) 不只盯主站: API 网关、内部 LB、Kafka mTLS、Redis TLS、数据库 TLS——所有 TLS 端点都要纳入清单。事故里"主站续了内网忘了"是高频踩坑点。
开放平台的额外提醒: 给三方应用的 webhook 回推用的也是 TLS——你的证书过期, 对方调你的服务收不到回推, 投诉会在工单里堆积。
复盘问题
- 用 30 秒讲清"信任锚 → 中间 CA → 叶子证书"三层链的验证流程。客户端验到哪一步会停下?
- TLS 1.3 为什么能 1-RTT, 而 TLS 1.2 必须 2-RTT? 关键的协议改动是什么?
- 0-RTT 的 early_data 有什么安全风险? 为什么 POST 请求不能走 0-RTT?
- SNI 解决了什么问题? 没有它的话, 一台服务器最多能配多少个 HTTPS 网站?
- 写一段 Go 代码: 让
http.Client信任一个自签 CA, 调用https://internal.api——不要InsecureSkipVerify。
今日检查清单
- 能画出"Root CA → Intermediate → Leaf"三层证书链, 并解释客户端是怎么验的
- 能用
openssl x509 -text看一张证书的 SAN / Validity / Key Usage 各字段 - 能区分 PEM / DER 两种编码, 知道何时该用哪个
- 能讲清 TLS 1.3 的 1-RTT 全过程, 以及它比 TLS 1.2 砍掉了什么
- 能解释 SNI / ALPN / ECH 各自解决的问题
- 用 openssl 在本地自建 CA, 签了服务端证书 + 客户端证书
- 跑通 Go HTTPS 服务端, 用 curl 加
--cacert调通 - 跑通 Go mTLS 服务端 + 客户端, 服务端能从
r.TLS.PeerCertificates[0]拿到客户端 CN
推荐阅读
RFC 8446 · TLS 1.3
2018 年的核心规范。重点读 §4 (Handshake Protocol), 配合 Wireshark 抓包对照, 一遍就能理解 1-RTT。
RFC 5280 · X.509 Path Validation
证书链验证的正式规范。§6 详细列了"客户端必须做的 10 步检查", 想做 PKI 工具的话必读。
High Performance Browser Networking
Ilya Grigorik 著 · Chapter 4 "Transport Layer Security" 是中文世界少有的把 TLS 性能讲透的章节。免费在线阅读。
Cloudflare TLS 1.3 系列
Cloudflare 是 TLS 1.3 标准化的主要推动者之一, 博客里有大量讲 0-RTT / ECH / Post-Quantum 的深度文章。
mkcert / cfssl / step
mkcert 是 Go 写的"傻瓜版本地 CA"——一行命令搞定本地 HTTPS 开发; cfssl / smallstep 是更完整的私有 CA 工具链, 适合企业 mTLS 部署。
Day 04 预告
会话管理与 Web 安全
三天打完密码学 / 协议层的地基, Day 04 回到应用层—— Session Fixation 是什么, 为什么 CSRF 攻击在 SPA 时代依然存在, XSS 怎么把 HttpOnly Cookie 一锅端, 以及 Day 01 那个 SameSite 究竟挡了什么。 看完 Day 04, 你能给团队评审 Cookie 配置, 也能在代码评审里一眼看出"这段写法可能开 XSS 洞"。