crypto/tls + crypto/x509 — TLS 与证书
crypto/tls 实现了 TLS 1.2(RFC 5246)与 TLS 1.3(RFC 8446),crypto/x509 负责证书解析、校验与生成。这一篇的重点是哪些 Config 字段其实已经失效、Go 近几个版本改了哪些握手默认值(后量子密钥交换、SHA-1、协议版本偏好)、以及生产上真正会踩的坑:会话恢复被静默关掉、InsecureSkipVerify 配 ServerName、mTLS 里"验了证书却没做授权"。
1. 先搞清楚 Config 的默认值
Go 的 TLS 默认值是"安全且现代"的,多数场景不该动它。GOROOT 文档里几个关键字段的原文语义:
| 字段 | 默认行为(文档核实) |
|---|---|
MinVersion |
默认已是 TLS 1.2(“TLS 1.2 is currently used as the minimum”),包支持的最低是 TLS 1.0。显式写 VersionTLS12 只是自我安慰 |
MaxVersion |
默认取本包支持的最高版本,目前是 TLS 1.3 |
CipherSuites |
只作用于 TLS 1.0–1.2;列表顺序被忽略;nil 时用安全默认集(默认集会随版本变动) |
CurvePreferences |
密钥交换机制集合,顺序同样被忽略(1.24 起"排序完全由 crypto/tls 决定");nil 时含后量子混合 KEX(见 §5) |
PreferServerCipherSuites |
legacy 字段,没有任何作用,且已标 Deprecated——服务器现在按客户端/服务器硬件与安全性自行选择套件 |
Rand |
已 Deprecated:生产应留 nil,且"不保证所有配置都使用 Rand";测试要用 testing/cryptotest.SetGlobalRandom |
NameToCertificate |
已 Deprecated:留 nil,让库从 Certificates 里挑第一条兼容链(用 GetCertificate 做选择) |
SessionTicketKey |
已 Deprecated:零值时每天自动轮换、七天后丢弃;要多实例共享/自定义轮换用 SetSessionTicketKeys |
注意:
PreferServerCipherSuites: true、CipherSuites: []uint16{...}这类"从 2015 年教程里抄来的配置"在新版 Go 上基本是无效噪声——前者完全不起作用,后者只影响 1.2 且顺序无关。TLS 1.3 的套件根本不可配置(文档原话 “TLS 1.3 ciphersuites are not configurable”)。想枚举可选值用tls.CipherSuites()/tls.InsecureCipherSuites(),打印用tls.CipherSuiteName(id)。
真正需要显式设置的场景很少:对接老旧设备时降 MinVersion、合规要求里限定版本、或者内部服务想关掉 TLS 1.3。
2. 服务端
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
return err
}
srv := &http.Server{Addr: ":8443", Handler: router}
srv.TLSConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: []string{"h2", "http/1.1"}, // 需要 HTTP/2 就得在这里宣告
}
// ListenAndServeTLS 的两个参数传空字符串,表示证书已经放进 TLSConfig
log.Fatal(srv.ListenAndServeTLS("", ""))ListenAndServeTLS(certFile, keyFile) 的两种形态别混:传文件名会走 LoadX509KeyPair;证书已在 TLSConfig.Certificates 里就传 "", ""。srv.ListenAndServeTLS 返回的 error 必须检查(TLS 配置错误会让进程"看起来起来了其实没监听",同 net-http.md 的 ListenAndServe 问题)。
2.1 按 SNI 动态选证书(含热加载)
GetCertificate 是唯一的正确入口——NameToCertificate 已废弃。
type certStore struct {
ptr atomic.Pointer[map[string]*tls.Certificate]
}
func (s *certStore) load(files map[string][2]string) error { // hostname -> {cert,key}
m := make(map[string]*tls.Certificate, len(files))
for host, p := range files {
c, err := tls.LoadX509KeyPair(p[0], p[1])
if err != nil {
return fmt.Errorf("%s: %w", host, err)
}
m[host] = &c
}
s.ptr.Store(&m)
return nil
}
tlsConf := &tls.Config{
GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
m := *s.ptr.Load()
if c, ok := m[chi.ServerName]; ok {
return c, nil
}
return m["*"], nil // 兜底证书,避免握手直接失败
},
}整张表用 atomic.Pointer 一次替换,读侧无锁;换证书不需要重启进程,也不会有"半更新"状态。这比 Certificates 数组 append + 锁要干净。GetConfigForClient 更进一步——它能返回一份全新的 *Config,做到按 SNI 区分版本/套件/客户端 CA(多租户网关常用)。
2.2 每连接的元数据
func handler(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil {
_ = r.TLS.NegotiatedProtocol // "h2" / "http/1.1"
_ = r.TLS.Version // tls.VersionTLS13
_ = r.TLS.CipherSuite // 用 tls.CipherSuiteName 打印
_ = r.TLS.PeerCertificates // mTLS 时非空
_ = r.TLS.VerifiedChains // 只有校验成功才有;RequireAnyClientCert 时为 nil
}
// Go 1.25+:ConnectionState.CurveID 告诉你这次握手实际用的密钥交换
}3. 客户端
caPEM, err := os.ReadFile("ca.crt")
if err != nil {
return err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return errors.New("no certificate found in PEM") // 解析失败不报错,只返回 false
}
tc := &tls.Config{
RootCAs: pool,
// 连 IP 或证书 CN 与目标不一致时,必须显式给 ServerName,否则校验必失败
ServerName: "internal-service.local",
// 会话恢复:不设置就没有 1-RTT 复用(见 §3.2)
ClientSessionCache: tls.NewLRUClientSessionCache(0),
}
tr := &http.Transport{
TLSClientConfig: tc,
ForceAttemptHTTP2: true, // 自己配了 TLSClientConfig 就要显式打开 h2
}三个容易忽略的点:
AppendCertsFromPEM不返回 error,只返回"是否至少加进一张"的 bool。忽略返回值 = CA 文件路径写错、格式不对时,报错表现成"x509: certificate signed by unknown authority",排查半天。- 配了
TLSClientConfig就默认放弃 HTTP/2(文档:“By default, use of any those fields conservatively disables HTTP/2”),要靠ForceAttemptHTTP2: true找回——注意它是"仍然通过 ALPN 协商 h2",不是明文 h2c(h2c 见 net-http.md 的Protocols)。 - 直连
tls.Dial/tls.Client时,ServerName既用于 SNI 又用于主机名校验;留空则两者都不做。手搓校验时别用InsecureSkipVerify: true+ 自己Verify,标准做法是自定义VerifyPeerCertificate/VerifyConnection(见 §3.1)。
3.1 校验钩子:VerifyConnection vs VerifyPeerCertificate
tc.VerifyConnection = func(cs tls.ConnectionState) error {
if cs.Version < tls.VersionTLS12 {
return fmt.Errorf("refusing TLS version %s", tls.VersionName(cs.Version))
}
// 证书固定(pinning):比对叶子证书公钥指纹,而不是整张证书(会随轮换失效)
leaf := cs.PeerCertificates[0]
sum := sha256.Sum256(leaf.Raw)
if !bytes.Equal(sum[:], wantSPKIPin) {
return errors.New("certificate pin mismatch")
}
return nil
}VerifyPeerCertificate(rawCerts, verifiedChains)在校验链算好之后调用;verifiedChains为 nil 意味着你关掉了默认校验(InsecureSkipVerify),此时必须自己chains.Verify(...)。VerifyConnection在它之后运行,且能拿到ConnectionState(版本、套件、CurveID、HandshakeContext),是更合适的策略落点。- 证书固定别固定 DER:证书一轮换全部客户端炸。固定公钥指纹(SPKI hash)或至少固定到 CA/中间 CA。
- 客户端做校验时
x509.HostnameError是"IP/域名与证书不匹配"的典型报错;对端是自签且用 IP 访问时,签名证书的IPAddresses必须包含该 IP,光有DNSNames: ["localhost"]不够。
3.2 会话恢复:默认是关的
Config.ClientSessionCache 注释写"only used by clients",而源码里 TLS 1.2 与 1.3 两条客户端握手路径都有同一句前置判断:if c.config.SessionTicketsDisabled || c.config.ClientSessionCache == nil { ... 不处理票据 }。net/http 的 Transport 从不设置这个字段(GOROOT 全文搜不到 ClientSessionCache),所以:
Go 程序高频访问同一个 HTTPS 端点,若没有自己配
ClientSessionCache,每次连接都是完整握手(TLS 1.3 全握手 1-RTT + 额外往返)。加上tls.NewLRUClientSessionCache(0)(容量 <1 时用默认值)就能走会话恢复,把后续握手降到 1-RTT 内复用。对延迟敏感的内部调用、批量爬虫、gRPC 客户端尤其明显。
服务端侧的票据密钥:SessionTicketKey 已废弃,改用 SetSessionTicketKeys([][32]byte{...})(可运行中轮换,第一个 key 用于签发,全部用于解密;传空切片 panic)。多实例部署必须共享票据密钥,否则会话恢复只在"落到签发实例"时才生效。
WrapSession/UnwrapSession 能把票据存到 Redis 等外部介质,但 GOROOT 文档有一条必须读进去的警告:
返回值的 warning 原文:“the return value will be exposed on the wire and to clients in plaintext. The application is in charge of encrypting and authenticating it (and rotating keys)…” —— 票据内容会明文出现在网络上,业务自己负责加密、认证和轮换,做错了会连累当前与未来的连接。
4. mTLS(双向认证)
// 服务端
caPEM, _ := os.ReadFile("ca.crt")
clientCAs := x509.NewCertPool()
clientCAs.AppendCertsFromPEM(caPEM)
srvConf := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.RequireAndVerifyClientCert, // ← 关键:不是 RequireAnyClientCert
ClientCAs: clientCAs,
}
// 客户端
cliConf := &tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caCertPool,
ServerName: "service.internal",
GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
return currentClientCert(), nil // 证书轮换/多身份时用回调
},
}ClientAuthType 六个值的差异是 mTLS 事故高发区:
| 值 | 行为 | 适用 |
|---|---|---|
NoClientCert |
不请求证书(默认) | 普通 HTTPS |
RequestClientCert |
请求但不验证,有就收下 | 几乎不该用 |
RequireAnyClientCert |
必须有证书,但不验证链 | 只做"有客户端证书"的形式检查,等于没有安全 |
VerifyClientCertIfGiven |
有则验证,无则放行 | 混合入口(内网 mTLS + 公网普通) |
RequireAndVerifyClientCert |
必须且验证链 | 服务间零信任的标准选择 |
VerifyClientCertIfGiven + 业务再判 |
常见组合 | — |
陷阱:mTLS 只完成"身份认证",不完成授权。握手成功后
r.TLS.PeerCertificates[0].Subject.CommonName是攻击者可控的字段(只要他有同一 CA 签的任意证书)。授权要基于你签发的证书里你写死的标识(DNSNames/EmailAddresses/URIs/自定义 OU/扩展 OID),或者叶子证书指纹;跨集群时再叠加 SPIFFE ID 之类的约定。用 CN 做权限判断是经典的"mTLS 越权"来源。
另一处:RequireAndVerifyClientCert 下握手失败在 TLS 层返回 remote error: tls: unknown certificate authority 之类的 alert,http.Server 的 ErrorLog 会记录但业务 handler 不会被调用——排障时别在 handler 里找原因。
5. 后量子密钥交换与 ECH(近几个版本默认值变了)
| 版本 | 变化 | 回退开关 |
|---|---|---|
| Go 1.23 | 客户端 ECH(Config.EncryptedClientHelloConfigList);QUIC 会话恢复事件;3DES 移出默认套件;实验性 X25519Kyber768Draft00 默认启用 |
tls3des=1、tlskyber=0 |
| Go 1.24 | X25519MLKEM768(标准化后的混合 PQC KEX)默认启用;Kyber draft 移除;CurvePreferences 顺序不再有意义;服务端 ECH(填 Config.EncryptedClientHelloKeys);ClientHelloInfo.Extensions |
tlsmlkem=0 |
| Go 1.25 | ConnectionState.CurveID(本次实际用的 KEX);GetEncryptedClientHelloKeys 回调;TLS 1.2 握手里 SHA-1 签名算法被禁用(RFC 9155);服务器改为优先自己支持的最高版本(不再顺从客户端偏好顺序);两侧都更严格拒绝 off-spec 行为 |
tlssha1=1 |
| Go 1.26 | 追加默认启用 SecP256r1MLKEM768 / SecP384r1MLKEM1024;ClientHelloInfo.HelloRetryRequest 与 ConnectionState.HelloRetryRequest;QUICConn 新增上报 TLS 握手错误的事件;私钥实现 crypto.MessageSigner 时 1.2+ 用 SignMessage |
tlssecpmlkem=0、tlsmlkem=0 |
| Go 1.27 | 新包 crypto/mldsa(FIPS 204 ML-DSA 签名);x509 支持 ML-DSA 公私钥与签名;TLS 1.3 支持 ML-DSA 签名(MLDSA44/MLDSA65/MLDSA87 三个 SignatureScheme) |
— |
实测一下就知道默认值已经走到哪一步(go1.27.0,本地自签 CA + net.Pipe 握手,ConnectionState 输出):
server: ver=TLS 1.3 suite=TLS_AES_128_GCM_SHA256 curve=4588 proto="" peerCerts=0
client: ver=TLS 1.3 suite=TLS_AES_128_GCM_SHA256 curve=4588 proto="" resumed=falsecurve=4588 就是 0x11EC = X25519MLKEM768——也就是说默认配置下已经在用后量子混合密钥交换,什么都不用配。proto="" 是因为这个例子里客户端没设 NextProtos:ALPN 需要两侧都声明才有结果,只在服务端 NextProtos 里写 "h2" 并不会自动协商出 HTTP/2(net/http 的 Transport 会自己补,裸 tls.Client 不会)。resumed=false 印证 §3.2:第一次握手必然全新。
几个实践结论:
- 默认值变了意味着线上可能出现"以前能连、现在连不上"。notes 里点名的就是这类:某些中间设备/老服务器不能正确处理大 TLS 记录,PQC 密钥使 ClientHello 变大后握手超时(社区称 “TLS post-quantum TL;DR fail”),1.24 的文档直接建议这种情况下用
GODEBUG=tlsmlkem=0。Go 1.25 的"更严格拒绝 off-spec" 同样会让老对端突然失败。定位手段:ConnectionState.CurveID+KeyLogWriter抓一份可解密的 pcap。 - 别手配
CurvePreferences去"优化":顺序无效,唯一作用是限定启用集合。真要关 PQC,用 GODEBUG 比改代码更容易在事故时回滚。 - ECH(Hello SHA… 不,Encrypted Client Hello) 用于把 SNI 也加密,目前生态(CDN、客户端)支持仍不完整,Go 侧客户端 1.23、服务端 1.24 起可用;内部系统一般用不上。
- ML-DSA 目前是签名侧的后量子方案(1.27 起 x509 与 TLS 1.3 支持)。要签自己的证书得让 CA 支持,短期内更多是准备而非生产可用;相关密钥类型见 hash-mac-aead.md。
6. QUIC 传输层支持
crypto/tls 把 TLS 1.3 的握手单独暴露成 QUICConn(QUICClient(*QUICConfig) / QUICServer(*QUICConfig)),供 QUIC 实现(x/net/http3、quic-go)取密钥与处理事件:
Start/Handshake/NextEvent/HandleMessage驱动状态机,SetTransportParameters传传输参数;QUICEncryptionLevel(Initial/Early/Handshake/Application)对应 QUIC 的分层密钥;- 1.23 起新增会话恢复相关事件并允许 QUIC 层往票据/缓存条目里附加数据;1.26 起新增"上报 TLS 握手错误"的事件。
这层不是给业务代码直接用的——HTTP/3 请引 golang.org/x/net/http3 或 quic-go(net/http 的 Protocols 只有 HTTP1/HTTP2/UnencryptedHTTP2,没有 HTTP/3;见 net-http.md §2.2)。
7. crypto/x509:生成与校验证书
7.1 完整的本地 CA + 服务器证书(可直接运行)
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"time"
)
func makeCA() (*x509.Certificate, *ecdsa.PrivateKey, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "local-dev-ca", Organization: []string{"dev"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true, // ← 忘了它 IsCA 不生效
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, nil, err
}
cert, err := x509.ParseCertificate(der)
return cert, key, err
}
func makeLeaf(ca *x509.Certificate, caKey *ecdsa.PrivateKey, hosts []string) ([]byte, []byte, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: hosts[0]},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(397 * 24 * time.Hour), // 浏览器要求叶子证书有效期 ≤ 398 天
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
tmpl.IPAddresses = append(tmpl.IPAddresses, ip) // IP 必须走这里
} else {
tmpl.DNSNames = append(tmpl.DNSNames, h)
}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey)
if err != nil {
return nil, nil, err
}
keyDER, err := x509.MarshalECPrivateKey(key)
return der, keyDER, err
}
func writePEM(path string, typ string, b []byte) error {
return os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: typ, Bytes: b}), 0o600)
}
func main() {
ca, caKey, err := makeCA()
if err != nil {
panic(err)
}
der, keyDER, err := makeLeaf(ca, caKey, []string{"localhost", "127.0.0.1", "::1"})
if err != nil {
panic(err)
}
// 已解析的证书直接取 ca.Raw(x509 没有 MarshalPKIXCertificate 这种再编码函数)
_ = writePEM("ca.crt", "CERTIFICATE", ca.Raw)
_ = writePEM("server.crt", "CERTIFICATE", der)
_ = writePEM("server.key", "EC PRIVATE KEY", keyDER)
// 自检:能被这条 CA 验证通过吗
pool := x509.NewCertPool()
pool.AddCert(ca)
leaf, _ := x509.ParseCertificate(der)
_, err = leaf.Verify(x509.VerifyOptions{
Roots: pool,
DNSName: "localhost",
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
CurrentTime: time.Now(),
Intermediates: x509.NewCertPool(),
})
fmt.Println("verify:", err)
}要点:
BasicConstraintsValid+IsCA必须成对设置(只写IsCA会被忽略);IP 地址要进IPAddresses,塞进DNSNames不参与主机名匹配;SerialNumber必须非零且带随机性(ParseCertificate现在会拒绝不规范的 serial 与负pathLenConstraint)。- 签名算法:1.25 起 TLS 1.2 握手禁用 SHA-1 签名,x509 侧也应一律 SHA-256 起;叶子证书有效期按浏览器策略压到 398 天以内。
- 喂给
tls.X509KeyPair的必须是 PEM。实测把CreateCertificate返回的 DER 直接传进去会报tls: failed to find any PEM data in certificate input;不想落盘就手工构造tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}(DER 放进Certificate的元素里)。 - 上面的例子用 ECDSA P-256 起步即可;
x509.MarshalECPrivateKey出来的是 SEC1 格式(PEM 头EC PRIVATE KEY),要 PKCS#8(PRIVATE KEY)用x509.MarshalPKCS8PrivateKey。
7.2 校验与信任库
cert, err := x509.ParseCertificate(der)
if err != nil { ... }
roots, err := x509.SystemCertPool() // 返回的是副本:改了不影响别的调用方
if err != nil { ... }
_, err = cert.Verify(x509.VerifyOptions{
Roots: roots,
DNSName: "example.com",
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
})
var hostErr x509.HostnameError // 主机名与证书 SAN 不匹配
var invalid x509.CertificateInvalidError // 过期/用途不符/基本约束不合法
switch {
case errors.As(err, &hostErr): // ...
case errors.As(err, &invalid): // ...
}- 报错类型可以程序化区分。实测(go1.27.0)用一份只签给
localhost的证书去校验evil.example,得到x509: certificate is valid for localhost, not evil.example,用var hostErr x509.HostnameError; errors.As(err, &hostErr)判定为 true;过期/用途不符走CertificateInvalidError。注意没有InvalidHostnameError这个类型(网上很多文章写错)。 SystemCertPool在 Linux 读文件(/etc/ssl/certs等),在 Windows/macOS 走系统 API;返回的是副本,改动不影响其他调用方,也不会落盘。- Go 1.27 起
SSL_CERT_FILE/SSL_CERT_DIR在 macOS 与 Windows 也生效(此前只有 Unix 生效),分隔符 Windows 用分号、其他用冒号。文档同时警告:在设有系统验证 API 的平台(macOS/Windows)上一旦设置这两个变量,就不再使用系统 API,除非GODEBUG=x509sslcertoverrideplatform=0。这是排查"证书明明装了却不被信任"时最容易被忽略的一条。 x509.CertPool一旦用于 TLS 就不要再改(文档要求RootCAs/ClientCAs在建好后只读),需要变化就整份重建再原子替换。- 1.26 起
x509.ExtKeyUsage/KeyUsage有String()(返回 RFC 5280 的 OID 名)和OID();新增x509.OIDFromASN1OID。1.25 起CreateCertificate*/CreateRevocationList接受crypto.MessageSigner(HSM/KMS 这类"一次性签名"私钥的标准接口),CreateCertificate缺SubjectKeyId时改用截断 SHA-256 填充(GODEBUG=x509sha256skid=0回退 SHA-1)。 - 1.27 起 x509 支持 ML-DSA 公私钥与签名(
crypto/mldsa)。
8. FIPS 模式与随机源
- 程序处于 FIPS 140-3 模式(用
GOFIPS140选择经认证的 Go 密码模块)时,crypto/tls表现得"仿佛只实现了 SP 800-140C/D 认可的版本、套件、签名算法、密钥类型与长度",其余被静默忽略或拒绝;1.25 在该模式下要求 TLS 1.2 使用 Extended Master Secret,并放开 Ed25519 与 X25519MLKEM768。 - Go 1.26 起
crypto各包的显式随机参数被统一忽略(一律走 CSPRNG),tls.Config.Rand、rsa.GenerateKey(rand, bits)的 rand 等不再起作用——测试要确定性随机源请改用testing/cryptotest.SetGlobalRandom(t, seed)。
9. 常见陷阱
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
照抄老教程配 PreferServerCipherSuites/CipherSuites |
前者完全无效,后者只管 TLS≤1.2 且顺序无关 | 相信默认值,只调 MinVersion |
InsecureSkipVerify: true 上生产 |
中间人攻击,等于没加密 | 自建 RootCAs;确有特殊校验需求时用 VerifyPeerCertificate 自己验 |
只设 InsecureSkipVerify 却期望 verifiedChains 非空 |
拿不到链,逻辑静默失效 | InsecureSkipVerify 后自己 x509.Certificate.Verify |
忽略 AppendCertsFromPEM 的返回值 |
CA 没加载成功,报 unknown authority | 判 bool,失败即报错 |
客户端不配 ClientSessionCache |
每次连接全握手,延迟高 | tls.NewLRUClientSessionCache(0) |
| 多实例不共享票据密钥 | 会话恢复命中率骤降 | SetSessionTicketKeys 同步(或外部存储 + WrapSession,注意明文警告) |
用 RequireAnyClientCert 当"已启用 mTLS" |
不验证链,任何自签证书都放行 | RequireAndVerifyClientCert + ClientCAs |
| 用对端证书 CN 做授权 | 同 CA 下任意持证者越权 | 授权基于自己控制的字段(URI/SAN/指纹/OID 扩展) |
连 IP 或自定义域名时 ServerName 留空 |
SNI 缺失 + 主机名校验失败 | 显式设 ServerName,并在证书里含该 IP/SAN |
| PQC KEX 上线后老对端握手超时 | 表现为"随机连不上",且抓包看不出问题 | GODEBUG=tlsmlkem=0 / tlssecpmlkem=0 回退,或升级中间设备 |
依赖 CurvePreferences 顺序 |
1.24 起顺序被忽略,白忙 | 只当启用集合用 |
| 老对端用 SHA-1 签名的证书 | 1.25 起 TLS 1.2 握手拒绝 | 换 SHA-256;应急 GODEBUG=tlssha1=1 |
设了 SSL_CERT_FILE 却在 macOS/Windows 上调试证书 |
1.27 起它会绕过系统信任库 | 明确环境差异;必要时 GODEBUG=x509sslcertoverrideplatform=0 |
ListenAndServeTLS 丢弃返回的 error |
进程活着但没服务 | 判 http.ErrServerClosed,其余 Fatal |
| 固定叶子证书 DER 做 pinning | 证书轮换后全部客户端失败 | 固定公钥指纹(SPKI SHA-256) |
10. 实战建议
- 默认配置就是当前最优解:
MinVersion已是 1.2,最高 1.3,套件与 KEX 由库决定。要动Config的字段,先问"这个字段现在还有作用吗"——PreferServerCipherSuites、Rand、NameToCertificate、SessionTicketKey都属于"历史包袱"。 - 服务端的证书问题用
GetCertificate+atomic.Pointer解决(热加载、多 SAN、按 SNI 分流),需要按连接改策略才用GetConfigForClient;这两条路径能覆盖 99% 的生产需求,且不需要重启。 - 客户端排障从
ConnectionState开始:Version、CipherSuite、CurveID、NegotiatedProtocol、VerifiedChains;配合KeyLogWriter导出密钥给 Wireshark,是唯一能证明"PQC 让 ClientHello 变大导致中间设备丢弃"的手段。生产上别长期开KeyLogWriter(它等于把会话密钥写盘)。 - mTLS 只解决认证,授权必须业务层基于自己签发的标识做判定;服务网格环境下交给 Istio/Linkerd 之类,自己实现的 mTLS 至少要覆盖"证书轮换 + 吊销"(
x509.CertificateRevocationList+ CRL 校验,或短周期证书 + 高频重签,后者是主流做法)。 - 升级 Go 版本前把 TLS 相关 GODEBUG 开关列进回归清单:
tlsmlkem、tlssecpmlkem、tlssha1、tls3des、x509sha256skid。它们的存在本身就说明"这些默认值会变"。 - 相关:
net-http.md(Server.Protocols/h2c/NextProtos与 HTTP/2 协商)、hash-mac-aead.md(crypto/mlkem/mldsa/hpke/aes-gcm/hkdf)、net.md(连接与超时)、os-file.md(证书文件权限 0600)。