From 8221ae82bc25d4d55ca64382207b69be71038cbb Mon Sep 17 00:00:00 2001 From: Benjamin Wang Date: Wed, 1 Jul 2026 14:06:33 +0100 Subject: [PATCH] Reject the client request if the client certificate has been revoked Signed-off-by: Benjamin Wang --- client/pkg/transport/listener_tls.go | 23 +++++++++++++++++++++++ server/embed/serve.go | 8 ++++++++ tests/e2e/ctl_v3_kv_test.go | 16 ++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/client/pkg/transport/listener_tls.go b/client/pkg/transport/listener_tls.go index 2c94841625b0..63566e1f6429 100644 --- a/client/pkg/transport/listener_tls.go +++ b/client/pkg/transport/listener_tls.go @@ -166,6 +166,29 @@ func (l *tlsListener) acceptLoop() { } } +// ConfigureCRLVerification appends a VerifyConnection hook to cfg that +// rejects any peer certificate whose serial number appears in the CRL file +// configured on info. It is a no-op when CRLFile is empty. Any existing +// VerifyConnection hook is called first and its error short-circuits. +func (info TLSInfo) ConfigureCRLVerification(cfg *tls.Config) { + if len(info.CRLFile) == 0 { + return + } + crlFile := info.CRLFile + prev := cfg.VerifyConnection + cfg.VerifyConnection = func(cs tls.ConnectionState) error { + if prev != nil { + if err := prev(cs); err != nil { + return err + } + } + if len(cs.PeerCertificates) == 0 { + return nil + } + return checkCRL(crlFile, cs.PeerCertificates) + } +} + func checkCRL(crlPath string, cert []*x509.Certificate) error { // TODO: cache crlBytes, err := os.ReadFile(crlPath) diff --git a/server/embed/serve.go b/server/embed/serve.go index e50529dfe438..2b28cf1cc6b9 100644 --- a/server/embed/serve.go +++ b/server/embed/serve.go @@ -233,6 +233,14 @@ func (sctx *serveCtx) serve( return tlsErr } + // In gRPC-only mode the gRPC stack owns the TLS handshake (via grpc.Creds). + // Wrapping sctx.l with a second TLS listener would cause a double-TLS failure, + // so inject CRL checking into the TLS config before the gRPC server is created + // (gRPC clones the config at creation time). + if onlyGRPC { + tlsinfo.ConfigureCRLVerification(tlscfg) + } + if grpcEnabled { gs = v3rpc.Server(s, tlscfg, nil, gopts...) v3electionpb.RegisterElectionServer(gs, servElection) diff --git a/tests/e2e/ctl_v3_kv_test.go b/tests/e2e/ctl_v3_kv_test.go index 82211946c35b..b79ebe5515ee 100644 --- a/tests/e2e/ctl_v3_kv_test.go +++ b/tests/e2e/ctl_v3_kv_test.go @@ -55,6 +55,22 @@ func TestCtlV3GetRevokedCRL(t *testing.T) { testCtl(t, testGetRevokedCRL, withCfg(*cfg)) } +// TestCtlV3GetRevokedCRLGRPCOnly is the same as TestCtlV3GetRevokedCRL but uses +// --listen-client-http-urls to split HTTP and gRPC onto separate ports. In that +// mode the gRPC server owns the TLS handshake directly (onlyGRPC=true in +// serve.go) and CRL checking must be injected via ConfigureCRLVerification +// rather than through the wrapping TLS listener used in the combined path. +func TestCtlV3GetRevokedCRLGRPCOnly(t *testing.T) { + cfg := e2e.NewConfig( + e2e.WithClusterSize(1), + e2e.WithClientConnType(e2e.ClientTLS), + e2e.WithClientRevokeCerts(true), + e2e.WithClientCertAuthority(true), + e2e.WithClientHTTPSeparate(true), + ) + testCtl(t, testGetRevokedCRL, withCfg(*cfg)) +} + func testGetRevokedCRL(cx ctlCtx) { // test reject require.ErrorContains(cx.t, ctlV3Put(cx, "k", "v", ""), "context deadline exceeded")