Manage TLS session tickets manually in QUIC peering (#62896)

* Manage TLS session tickets manually in QUIC peering

* Remove static assert on return types
This commit is contained in:
Edoardo Spadolini
2026-01-16 09:49:44 +00:00
committed by GitHub
parent 537846890b
commit aabe46f84f
2 changed files with 120 additions and 1 deletions
+68 -1
View File
@@ -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
}
+52
View File
@@ -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])
}