双向 TLS 在验证服务器身份的同时,也认证客户端是否能够使用证书对应的私钥。但它并不天然证明某台物理设备的身份。可复制的软件私钥可以被带到别处使用;硬件绑定和设备证明需要额外机制。注册和签发策略可以把密钥关联到设备,但 TLS 本身不会自动建立或验证这项硬件关联。

完成认证仍然需要授权

TLS 1.3 的证书式客户端认证中,服务器请求证书,客户端通过 CertificateVerify 证明自己能够使用对应私钥。只发送一份公开证书文件并不够。双方都必须应用各自的信任规则,检查证书链和适当用途;客户端还要验证服务器名称,不能因为自己提交了凭据就放弃验证对端。

服务端信任哪些客户端 CA,与客户端信任哪些服务器 CA,是两个方向的决定。应只接受预期签发者,并明确使用哪个已验证证书字段表示工作负载身份。URI SAN 可以存放应用标识,但谁有资格拿到这个标识,取决于签发者的注册规则。证书里出现了名字,不代表它自动拥有所有 API 权限。

“要求必须提供证书”也不同于“请求对方有的话就提供”。Go 的 RequireAndVerifyClientCert 要求有效客户端证书,而可选模式可能允许没有证书的连接通过。认证之后仍需根据已验证身份执行授权,因此合法访客可以得到 HTTP 403,而获准读取的客户端得到 HTTP 200,这两个结果不矛盾。

坏掉的样子

没有证书、签发者不受信任、凭据过期或扩展用途不合适,都可能阻止客户端认证。把服务端改为任意证书都接受,或者关闭客户端对服务器的验证,只是隐藏边界,不是在修复凭据问题。应区分握手失败和应用层拒绝,让排查人员能定位到实际负责的那一层。

私钥需要受控存储、签发、轮换和事件响应。续签证书并不必然更换私钥;被盗且仍被接受的私钥可能用于冒充,不能把等待过期当作唯一处置方式。Go 的证书 Verify 不执行吊销检查,因此发布了一条吊销记录,也不等于每个调用方已经应用它,接受策略必须另行核实。

TLS 终止位置还决定了身份在哪里被建立。后端收到调用方自行填写的身份 Header,不代表后端就验证了调用方证书。受信任的身份传递需要自己的认证边界和防伪造规则;凭据或权限变化时,既有连接、恢复会话如何处理,也必须有明确的生命周期策略。

快速验证

保存为 mtls_probe.go,使用 Go 1.23 或更新版本运行 go run mtls_probe.go。程序在内存中生成客户端 CA 和凭据,把 notBefore 回拨一分钟,并把 notAfter 设为生成时刻之后一小时。回环服务端使用 httptest 自带的测试服务器证书,配套测试客户端信任这张证书。两侧信任库都不安装到系统全局,也不打印或写出私钥。

package main

import (
	"crypto/ed25519"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"errors"
	"fmt"
	"io"
	"log"
	"math/big"
	"net/http"
	"net/http/httptest"
	"net/url"
	"runtime"
	"sync/atomic"
	"time"
)

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func authority(serial int64) (*x509.Certificate, ed25519.PrivateKey) {
	public, private, err := ed25519.GenerateKey(rand.Reader)
	must(err)
	now := time.Now()
	template := &x509.Certificate{
		SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: fmt.Sprint("fixture-ca-", serial)},
		NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
		IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
	}
	der, err := x509.CreateCertificate(rand.Reader, template, template, public, private)
	must(err)
	certificate, err := x509.ParseCertificate(der)
	must(err)
	return certificate, private
}

func credential(serial int64, identity string, usage x509.ExtKeyUsage, ca *x509.Certificate, signer ed25519.PrivateKey) tls.Certificate {
	public, private, err := ed25519.GenerateKey(rand.Reader)
	must(err)
	name, err := url.Parse("urn:example:" + identity)
	must(err)
	now := time.Now()
	template := &x509.Certificate{
		SerialNumber: big.NewInt(serial), URIs: []*url.URL{name},
		NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
		KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage},
	}
	der, err := x509.CreateCertificate(rand.Reader, template, ca, public, signer)
	must(err)
	certificate, err := x509.ParseCertificate(der)
	must(err)
	return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: private, Leaf: certificate}
}

func main() {
	ca, signer := authority(1)
	foreignCA, foreignSigner := authority(2)
	reader := credential(3, "reader", x509.ExtKeyUsageClientAuth, ca, signer)
	guest := credential(4, "guest", x509.ExtKeyUsageClientAuth, ca, signer)
	foreign := credential(5, "reader", x509.ExtKeyUsageClientAuth, foreignCA, foreignSigner)
	serverOnly := credential(6, "reader", x509.ExtKeyUsageServerAuth, ca, signer)
	roots := x509.NewCertPool()
	roots.AddCert(ca)
	options := x509.VerifyOptions{Roots: roots, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}
	_, err := foreign.Leaf.Verify(options)
	var unknown x509.UnknownAuthorityError
	check(errors.As(err, &unknown), "foreign issuer fixture must fail trust")
	_, err = serverOnly.Leaf.Verify(options)
	var invalid x509.CertificateInvalidError
	check(errors.As(err, &invalid) && invalid.Reason == x509.IncompatibleUsage, "server-only fixture must fail client usage")

	var handled atomic.Int64
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handled.Add(1)
		check(len(r.TLS.VerifiedChains) > 0, "handler requires a verified client chain")
		identities := r.TLS.VerifiedChains[0][0].URIs
		if len(identities) != 1 || identities[0].String() != "urn:example:reader" {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		fmt.Fprintln(w, "reader allowed")
	}))
	server.Config.ErrorLog = log.New(io.Discard, "", 0)
	server.TLS = &tls.Config{
		MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13,
		ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: roots, SessionTicketsDisabled: true,
	}
	server.StartTLS()
	defer server.Close()
	request := func(certificate *tls.Certificate) (int, error) {
		config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
		check(!config.InsecureSkipVerify, "server identity verification must remain enabled")
		config.MinVersion = tls.VersionTLS13
		if certificate != nil {
			config.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
				return certificate, nil
			}
		}
		transport := &http.Transport{TLSClientConfig: config, DisableKeepAlives: true}
		defer transport.CloseIdleConnections()
		client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
		request, err := http.NewRequest(http.MethodGet, server.URL, nil)
		must(err)
		request.Header.Set("X-Client-ID", "urn:example:reader")
		response, err := client.Do(request)
		if err != nil {
			return 0, err
		}
		defer response.Body.Close()
		_, err = io.Copy(io.Discard, response.Body)
		must(err)
		check(response.TLS.Version == tls.VersionTLS13 && len(response.TLS.VerifiedChains) > 0, "client requires a verified TLS 1.3 server")
		return response.StatusCode, nil
	}
	for _, test := range []struct {
		name        string
		certificate *tls.Certificate
		status      int
	}{
		{"reader", &reader, 200}, {"guest", &guest, 403},
		{"missing", nil, 0}, {"foreign issuer", &foreign, 0},
		{"wrong usage", &serverOnly, 0}, {"reader again", &reader, 200},
	} {
		status, err := request(test.certificate)
		if test.status == 0 {
			check(err != nil && status == 0, "invalid client must fail before HTTP")
		} else {
			must(err)
			check(status == test.status, "wrong authorization result")
		}
		fmt.Println(test.name, "HTTP status:", status)
	}
	check(handled.Load() == 3, "only reader, guest, and reader again may reach HTTP")
	fmt.Println("three TLS rejections; authenticated guest denied despite claimed reader header")
	fmt.Println(runtime.Version())
}

reader 返回 200;同一个可信 CA 签发的 guest 返回 403,即使它在 X-Client-ID 中声称自己是 reader。授权使用已验证证书中的精确 URI SAN,不使用这个 Header。本例的 URN 只是应用身份标签,不是网络地址,固定规则只允许 urn:example:reader。

缺少凭据、外部 CA 签发的证书、仅允许服务端认证用途的证书,都在进入 HTTP 之前失败。此时打印的零只是“没有 HTTP 响应”的内部标记,不是一个真实 HTTP 状态码。独立的 X.509 检查还确认了两种非法证书分别触发 UnknownAuthorityError 和 IncompatibleUsage。最后再次请求 reader 应恢复成功,总共只有三次请求进入 handler。

证书回调刻意返回每次指定的凭据,包括外部 CA 的证书,而不是根据服务器公布的 CA 偏好默默选成“没有证书”。这样才能覆盖服务端拒绝不可信证书的路径;生产中的证书选择仍应遵守证书请求。本例每次都使用新的 transport,设置两秒超时,并关闭 HTTP 连接复用与会话票据。

已核验的 macOS、Go 1.27.0 运行通过全部断言。这验证了真实双向 TLS 和一条明确的应用授权规则,不是硬件身份或生产 PKI 的验收。实验没有测试吊销分发、自动续期、过期客户端在握手中的行为、恢复会话、反向代理身份传递,或既有连接上的权限变化。这些事项仍需在实际部署边界单独检查。

延伸阅读

来源

  • RFC 8446:TLS 1.3,证书请求与私钥控制能力证明,已核验 2026-09-09。
  • Go crypto/tls,客户端认证模式、信任池和证书选择,已核验 2026-09-09。
  • Go crypto/x509,客户端认证用途、验证链与吊销检查边界,已核验 2026-09-09。
  • Go net/http/httptest,隔离 TLS 服务和可信测试客户端,已核验 2026-09-09。