From aabe46f84f3d436037a8341de269c23b852a8235 Mon Sep 17 00:00:00 2001 From: Edoardo Spadolini Date: Fri, 16 Jan 2026 10:49:44 +0100 Subject: [PATCH] Manage TLS session tickets manually in QUIC peering (#62896) * Manage TLS session tickets manually in QUIC peering * Remove static assert on return types --- lib/proxy/peer/quic/server.go | 69 +++++++++++++++++++++++++++++- lib/proxy/peer/quic/server_test.go | 52 ++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/lib/proxy/peer/quic/server.go b/lib/proxy/peer/quic/server.go index f065cf48817..c5c9a8455a4 100644 --- a/lib/proxy/peer/quic/server.go +++ b/lib/proxy/peer/quic/server.go @@ -18,6 +18,7 @@ package quic import ( "context" + "crypto/rand" "crypto/tls" "crypto/x509" "encoding/binary" @@ -25,6 +26,7 @@ import ( "log/slog" "strings" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -133,15 +135,17 @@ func NewServer(cfg ServerConfig) (*Server, error) { } getClientCAs := cfg.GetClientCAs + str := new(sessionTicketRefresher) tlsConfig.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) { clientCAs, err := getClientCAs(chi) if err != nil { return nil, trace.Wrap(err) } - utils.RefreshTLSConfigTickets(tlsConfig) c := tlsConfig.Clone() + c.GetConfigForClient = nil c.ClientCAs = clientCAs + c.SetSessionTicketKeys(str.getSessionTicketKeys()) return c, nil } @@ -477,3 +481,66 @@ func (r *replayStore) add(nonce uint64, now time.Time) (added bool) { r.currentSet[nonce] = struct{}{} return true } + +// sessionTicketRefresherState contains a non-empty slice of randomly generated +// session tickets together with a slice of creation times that has the same +// length and is in descending order. +type sessionTicketRefresherState struct { + tickets [][32]byte + created []time.Time +} + +// sessionTicketRefresher generates and rotates TLS session tickets suitable for +// use with [tls.Config.SetSessionTicketKeys]. +type sessionTicketRefresher struct { + // state is either nil or a pointer to a valid (i.e. non-zero) state struct. + state atomic.Pointer[sessionTicketRefresherState] + // mu should be held before calculating and storing a new state, to avoid + // extra work. + mu sync.Mutex +} + +// getSessionTicketKeys returns a slice of session tickets, potentially adding a +// new one and rotating out expired old ones. A new ticket is created when the +// most recent is a day old, and tickets are rotated away after a week or so. +func (s *sessionTicketRefresher) getSessionTicketKeys() [][32]byte { + // same logic for the fast path as in crypto/tls, including the fact that + // the oldest ticket can actually be up to 8 days old since we don't check + // for expiry in the fast path + if state := s.state.Load(); state != nil && time.Since(state.created[0]) < 24*time.Hour { + return state.tickets + } + + s.mu.Lock() + defer s.mu.Unlock() + + state := s.state.Load() + now := time.Now() + if state != nil && now.Sub(state.created[0]) < 24*time.Hour { + return state.tickets + } + + if state == nil { + state = new(sessionTicketRefresherState) + } + newState := &sessionTicketRefresherState{ + tickets: make([][32]byte, 0, 1+len(state.tickets)), + created: make([]time.Time, 0, 1+len(state.tickets)), + } + + var newTicket [32]byte + rand.Read(newTicket[:]) + newState.tickets = append(newState.tickets, newTicket) + newState.created = append(newState.created, now) + + for i := range state.tickets { + if now.Sub(state.created[i]) >= 7*24*time.Hour { + break + } + newState.tickets = append(newState.tickets, state.tickets[i]) + newState.created = append(newState.created, state.created[i]) + } + s.state.Store(newState) + + return newState.tickets +} diff --git a/lib/proxy/peer/quic/server_test.go b/lib/proxy/peer/quic/server_test.go index 40f2c66456f..b4533977ac4 100644 --- a/lib/proxy/peer/quic/server_test.go +++ b/lib/proxy/peer/quic/server_test.go @@ -19,6 +19,8 @@ package quic import ( "encoding/binary" "testing" + "testing/synctest" + "time" "github.com/stretchr/testify/require" "google.golang.org/grpc/status" @@ -42,3 +44,53 @@ func TestDialResponseOKEncoding(t *testing.T) { require.NoError(t, err) require.Equal(t, dialResponseOK, string(sizedResp)) } + +func TestSessionTicketRefresher(t *testing.T) { + synctest.Test(t, testSessionTicketRefresher) +} +func testSessionTicketRefresher(t *testing.T) { + str := new(sessionTicketRefresher) + + t1 := time.Now() + k1 := str.getSessionTicketKeys() + require.Len(t, k1, 1) + requireSameSlice(t, k1, str.getSessionTicketKeys()) + + time.Sleep(time.Hour) + + // the first key is still valid, no keys are added + + requireSameSlice(t, k1, str.getSessionTicketKeys()) + + requireSameSlice(t, k1, str.state.Load().tickets) + require.True(t, t1.Equal(str.state.Load().created[0])) + + time.Sleep(24 * time.Hour) + + // the first key is valid but old, a new fresh key is generated + + t2 := time.Now() + k2 := str.getSessionTicketKeys() + + require.Len(t, k2, 2) + requireSameSlice(t, k2, str.state.Load().tickets) + require.True(t, t2.Equal(str.state.Load().created[0])) + require.True(t, t1.Equal(str.state.Load().created[1])) + + time.Sleep(6 * 24 * time.Hour) + + // the first key should be rotated out + + t3 := time.Now() + k3 := str.getSessionTicketKeys() + + require.Len(t, k3, 2) + requireSameSlice(t, k3, str.state.Load().tickets) + require.True(t, t3.Equal(str.state.Load().created[0])) + require.True(t, t2.Equal(str.state.Load().created[1])) +} + +func requireSameSlice[S ~[]T, T any](t *testing.T, expected, actual S) { + require.Len(t, actual, len(expected)) + require.Same(t, &expected[0], &actual[0]) +}