@@ -7,22 +7,25 @@ import (
77 "crypto/ecdsa"
88 "crypto/elliptic"
99 "crypto/rand"
10+ "crypto/tls"
1011 "crypto/x509"
1112 "crypto/x509/pkix"
1213 "encoding/pem"
14+ "fmt"
1315 "math/big"
1416 "os"
1517 "path/filepath"
18+ "sync"
1619 "testing"
1720 "time"
1821
1922 "github.com/stretchr/testify/require"
2023 "go.uber.org/goleak"
2124 "google.golang.org/grpc"
2225 "google.golang.org/grpc/backoff"
26+ "google.golang.org/grpc/credentials"
2327
2428 v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
25- "github.com/authzed/grpcutil"
2629
2730 "github.com/authzed/spicedb/internal/datastore/dsfortesting"
2831 "github.com/authzed/spicedb/internal/dispatch/graph"
@@ -37,19 +40,34 @@ import (
3740 "github.com/authzed/spicedb/pkg/zedtoken"
3841)
3942
43+ // servedCertRecorder records the leaf certificate presented by the server
44+ // during each TLS handshake. It is safe for concurrent use.
45+ type servedCertRecorder struct {
46+ mu sync.Mutex
47+ last * x509.Certificate
48+ }
49+
50+ func (r * servedCertRecorder ) verifyConnection (cs tls.ConnectionState ) error {
51+ if len (cs .PeerCertificates ) == 0 {
52+ return fmt .Errorf ("server presented no certificates" )
53+ }
54+ r .mu .Lock ()
55+ defer r .mu .Unlock ()
56+ r .last = cs .PeerCertificates [0 ]
57+ return nil
58+ }
59+
60+ func (r * servedCertRecorder ) lastServedCert () * x509.Certificate {
61+ r .mu .Lock ()
62+ defer r .mu .Unlock ()
63+ return r .last
64+ }
65+
4066func TestCertRotation (t * testing.T ) {
4167 t .Cleanup (func () {
4268 goleak .VerifyNone (t , testutil .GoLeakIgnores ()... )
4369 })
4470
45- const (
46- // length of time the initial cert is valid
47- initialValidDuration = 3 * time .Second
48-
49- // continue making requests for waitFactor*initialValidDuration
50- waitFactor = 2
51- )
52-
5371 certDir := t .TempDir ()
5472
5573 ca := & x509.Certificate {
@@ -71,21 +89,19 @@ func TestCertRotation(t *testing.T) {
7189 require .NoError (t , err )
7290 caFile , err := os .Create (filepath .Join (certDir , "ca.crt" ))
7391 require .NoError (t , err )
74- t .Cleanup (func () {
75- require .NoError (t , caFile .Close ())
76- })
7792 require .NoError (t , pem .Encode (caFile , & pem.Block {
7893 Type : "CERTIFICATE" ,
7994 Bytes : caCert .Raw ,
8095 }))
96+ require .NoError (t , caFile .Close ())
8197
8298 old := & x509.Certificate {
8399 SerialNumber : big .NewInt (1 ),
84100 Subject : pkix.Name {
85101 Organization : []string {"initialTestCert" },
86102 },
87103 NotBefore : time .Now (),
88- NotAfter : time .Now ().Add (initialValidDuration ),
104+ NotAfter : time .Now ().Add (5 * time . Minute ),
89105 ExtKeyUsage : []x509.ExtKeyUsage {x509 .ExtKeyUsageClientAuth , x509 .ExtKeyUsageServerAuth },
90106 KeyUsage : x509 .KeyUsageDigitalSignature | x509 .KeyUsageCertSign ,
91107 BasicConstraintsValid : true ,
@@ -133,10 +149,13 @@ func TestCertRotation(t *testing.T) {
133149 server .WithMaximumPreconditionCount (1000 ),
134150 server .WithMaximumUpdatesPerWrite (1000 ),
135151 server .WithGRPCServer (util.GRPCServerConfig {
136- Network : util .BufferedNetwork ,
137- Enabled : true ,
138- TLSCertPath : certFile .Name (),
139- TLSKeyPath : keyFile .Name (),
152+ Network : util .BufferedNetwork ,
153+ Enabled : true ,
154+ TLSCertPath : certFile .Name (),
155+ TLSKeyPath : keyFile .Name (),
156+ // ClientCAPath is used by the server's outbound dispatch credentials, not
157+ // for server-side client-cert validation — the server does not set ClientAuth,
158+ // so this test does not exercise mutual TLS.
140159 ClientCAPath : caFile .Name (),
141160 }),
142161 server .WithGRPCAuthFunc (func (ctx context.Context ) (context.Context , error ) {
@@ -202,43 +221,60 @@ func TestCertRotation(t *testing.T) {
202221 wait <- err
203222 }()
204223
205- tlsCreds , err := grpcutil . WithCustomCerts ( grpcutil . VerifyCA , caFile . Name () )
206- require . NoError ( t , err )
224+ caPool := x509 . NewCertPool ( )
225+ caPool . AddCert ( caCert )
207226
208- // "buffnet" matches the DNSNames in the TLS certificate issued above; WithAuthority
209- // sets the SNI so TLS verification succeeds even though the dial target is localhost.
210- conn , err := srv .NewClient (
211- tlsCreds ,
212- grpc .WithAuthority ("buffnet" ),
213- grpc .WithConnectParams (grpc.ConnectParams {
214- Backoff : backoff.Config {
215- BaseDelay : 1 * time .Second ,
216- Multiplier : 2 ,
217- MaxDelay : 15 * time .Second ,
227+ // newConn dials a brand-new *grpc.ClientConn with its own servedCertRecorder.
228+ // A new ClientConn has no cached TLS session state (nil ClientSessionCache),
229+ // so every dial performs a full handshake and VerifyConnection fires to capture
230+ // the leaf cert the server currently presents.
231+ newConn := func () (* grpc.ClientConn , * servedCertRecorder , error ) {
232+ rec := & servedCertRecorder {}
233+ creds := credentials .NewTLS (& tls.Config {
234+ RootCAs : caPool ,
235+ ServerName : "buffnet" ,
236+ MinVersion : tls .VersionTLS12 ,
237+ VerifyConnection : rec .verifyConnection ,
238+ })
239+ conn , err := srv .NewClient (
240+ grpc .WithTransportCredentials (creds ),
241+ grpc .WithConnectParams (grpc.ConnectParams {
242+ Backoff : backoff.Config {
243+ BaseDelay : 1 * time .Second ,
244+ Multiplier : 2 ,
245+ MaxDelay : 15 * time .Second ,
246+ },
247+ }),
248+ )
249+ return conn , rec , err
250+ }
251+
252+ rel := tuple .ToV1Relationship (tuple .MustParse (tf .StandardRelationships [0 ]))
253+
254+ checkPermission := func (client v1.PermissionsServiceClient ) error {
255+ _ , err := client .CheckPermission (ctx , & v1.CheckPermissionRequest {
256+ Consistency : & v1.Consistency {
257+ Requirement : & v1.Consistency_AtLeastAsFresh {
258+ AtLeastAsFresh : zedtoken .MustNewFromRevisionForTesting (revision , datalayer .NoSchemaHashInLegacyZedToken ),
259+ },
218260 },
219- }),
220- )
261+ Resource : rel .Resource ,
262+ Permission : "viewer" ,
263+ Subject : rel .Subject ,
264+ })
265+ return err
266+ }
221267
268+ conn , rec , err := newConn ()
222269 require .NoError (t , err )
223270 defer func () {
224- if conn != nil {
225- require .NoError (t , conn .Close ())
226- }
271+ require .NoError (t , conn .Close ())
227272 }()
228- // requests work with the old key
229- client := v1 .NewPermissionsServiceClient (conn )
230- rel := tuple .ToV1Relationship (tuple .MustParse (tf .StandardRelationships [0 ]))
231- _ , err = client .CheckPermission (ctx , & v1.CheckPermissionRequest {
232- Consistency : & v1.Consistency {
233- Requirement : & v1.Consistency_AtLeastAsFresh {
234- AtLeastAsFresh : zedtoken .MustNewFromRevisionForTesting (revision , datalayer .NoSchemaHashInLegacyZedToken ),
235- },
236- },
237- Resource : rel .Resource ,
238- Permission : "viewer" ,
239- Subject : rel .Subject ,
240- })
241- require .NoError (t , err )
273+
274+ require .NoError (t , checkPermission (v1 .NewPermissionsServiceClient (conn )))
275+ served := rec .lastServedCert ()
276+ require .NotNil (t , served )
277+ require .Equal (t , oldCert .SerialNumber , served .SerialNumber , "expected the initial certificate to be served before rotation" )
242278
243279 // rotate the key
244280 newCert := & x509.Certificate {
@@ -279,21 +315,26 @@ func TestCertRotation(t *testing.T) {
279315 }))
280316 require .NoError (t , certFile .Close ())
281317
282- // check for waitFactor*initialValidDuration seconds
283- for range waitFactor {
284- _ , err = client .CheckPermission (ctx , & v1.CheckPermissionRequest {
285- Consistency : & v1.Consistency {
286- Requirement : & v1.Consistency_AtLeastAsFresh {
287- AtLeastAsFresh : zedtoken .MustNewFromRevisionForTesting (revision , datalayer .NoSchemaHashInLegacyZedToken ),
288- },
289- },
290- Resource : rel .Resource ,
291- Permission : "viewer" ,
292- Subject : rel .Subject ,
293- })
294- require .NoError (t , err )
295- time .Sleep (initialValidDuration )
296- }
318+ // Wait for the server to reload the rotated certificate. Each iteration dials
319+ // a fresh connection to force a new TLS handshake — a reused connection keeps
320+ // its existing session and would never re-present the cert. This assertion
321+ // fails if hot reloading in updateCachedCertificate is disabled.
322+ require .Eventually (t , func () bool {
323+ rotatedConn , rotatedRec , err := newConn ()
324+ if err != nil {
325+ t .Logf ("newConn failed: %v" , err )
326+ return false
327+ }
328+ defer func () { _ = rotatedConn .Close () }()
329+
330+ if err := checkPermission (v1 .NewPermissionsServiceClient (rotatedConn )); err != nil {
331+ t .Logf ("checkPermission failed: %v" , err )
332+ return false
333+ }
334+
335+ served := rotatedRec .lastServedCert ()
336+ return served != nil && served .SerialNumber .Cmp (newCertParsed .SerialNumber ) == 0
337+ }, 30 * time .Second , 500 * time .Millisecond , "server never served the rotated certificate" )
297338
298339 cancel ()
299340 select {
0 commit comments