@@ -25,13 +25,17 @@ import (
2525 "crypto/tls"
2626 "crypto/x509"
2727 "crypto/x509/pkix"
28+ "encoding/asn1"
2829 "encoding/json"
2930 "encoding/pem"
31+ "io"
3032 "math/big"
3133 "net/http"
3234 "net/http/httptest"
35+ "net/http/httptrace"
3336 "os"
3437 "path/filepath"
38+ "strconv"
3539 "strings"
3640 "testing"
3741 "time"
@@ -404,3 +408,179 @@ func TestRootHandler_VersionIsAnonymous(t *testing.T) {
404408 t .Errorf ("body = %q, want the build info rather than the SPA" , rec .Body .String ())
405409 }
406410}
411+
412+ // writeOperatorCA writes a self-signed operator CA to dir and returns its path
413+ // with the parsed certificate and key, so a test can issue operator leaves.
414+ func writeOperatorCA (t * testing.T , dir string ) (string , * x509.Certificate , * ecdsa.PrivateKey ) {
415+ t .Helper ()
416+ key , err := ecdsa .GenerateKey (elliptic .P256 (), rand .Reader )
417+ if err != nil {
418+ t .Fatal (err )
419+ }
420+ tmpl := & x509.Certificate {
421+ SerialNumber : big .NewInt (1 ),
422+ Subject : pkix.Name {CommonName : "operator CA" },
423+ NotBefore : time .Now ().Add (- time .Hour ),
424+ NotAfter : time .Now ().Add (time .Hour ),
425+ IsCA : true ,
426+ BasicConstraintsValid : true ,
427+ KeyUsage : x509 .KeyUsageCertSign ,
428+ }
429+ der , err := x509 .CreateCertificate (rand .Reader , tmpl , tmpl , & key .PublicKey , key )
430+ if err != nil {
431+ t .Fatal (err )
432+ }
433+ ca , err := x509 .ParseCertificate (der )
434+ if err != nil {
435+ t .Fatal (err )
436+ }
437+ path := filepath .Join (dir , "operator-ca.pem" )
438+ if err := os .WriteFile (path , pem .EncodeToMemory (& pem.Block {Type : "CERTIFICATE" , Bytes : der }), 0o600 ); err != nil {
439+ t .Fatal (err )
440+ }
441+ return path , ca , key
442+ }
443+
444+ // operatorLeaf issues a client certificate carrying the admin access level.
445+ func operatorLeaf (t * testing.T , ca * x509.Certificate , caKey * ecdsa.PrivateKey ) tls.Certificate {
446+ t .Helper ()
447+ key , err := ecdsa .GenerateKey (elliptic .P256 (), rand .Reader )
448+ if err != nil {
449+ t .Fatal (err )
450+ }
451+ value , err := authz .MarshalLevelValue (authz .LevelAdmin )
452+ if err != nil {
453+ t .Fatal (err )
454+ }
455+ oid , err := asn1ObjectIdentifier (authz .AccessLevelOID )
456+ if err != nil {
457+ t .Fatal (err )
458+ }
459+ tmpl := & x509.Certificate {
460+ SerialNumber : big .NewInt (2 ),
461+ Subject : pkix.Name {CommonName : "op@acme.example" },
462+ NotBefore : time .Now ().Add (- time .Hour ),
463+ NotAfter : time .Now ().Add (time .Hour ),
464+ KeyUsage : x509 .KeyUsageDigitalSignature ,
465+ ExtKeyUsage : []x509.ExtKeyUsage {x509 .ExtKeyUsageClientAuth },
466+ ExtraExtensions : []pkix.Extension {{Id : oid , Value : value }},
467+ }
468+ der , err := x509 .CreateCertificate (rand .Reader , tmpl , ca , & key .PublicKey , caKey )
469+ if err != nil {
470+ t .Fatal (err )
471+ }
472+ return tls.Certificate {Certificate : [][]byte {der }, PrivateKey : key }
473+ }
474+
475+ // asn1ObjectIdentifier parses a dotted OID.
476+ func asn1ObjectIdentifier (dotted string ) (asn1.ObjectIdentifier , error ) {
477+ var oid asn1.ObjectIdentifier
478+ for _ , part := range strings .Split (dotted , "." ) {
479+ n , err := strconv .Atoi (part )
480+ if err != nil {
481+ return nil , err
482+ }
483+ oid = append (oid , n )
484+ }
485+ return oid , nil
486+ }
487+
488+ // connTracker records whether each request went out on a reused connection.
489+ func connTracker (req * http.Request , reused * bool ) * http.Request {
490+ trace := & httptrace.ClientTrace {GotConn : func (info httptrace.GotConnInfo ) { * reused = info .Reused }}
491+ return req .WithContext (httptrace .WithClientTrace (req .Context (), trace ))
492+ }
493+
494+ // TestServe_CertlessConnectionIsNotReusedForTheAPI reproduces #77 over a real
495+ // TLS listener speaking HTTP/2 with the production TLS posture. The browser's
496+ // first request is for the anonymous web surface, so that handshake completes
497+ // with no client certificate. HTTP/2 then reuses the connection for the API,
498+ // which refuses it -- correctly, and it must keep doing so -- but a connection
499+ // with no certificate can never authenticate, so it must not stay open for the
500+ // next API call either. The server closes it, and the client's next request
501+ // performs a fresh handshake where the certificate can be offered.
502+ func TestServe_CertlessConnectionIsNotReusedForTheAPI (t * testing.T ) {
503+ caPath , ca , caKey := writeOperatorCA (t , t .TempDir ())
504+ tlsCfg , err := buildTLSConfig (config.Config {OperatorCAPath : caPath })
505+ if err != nil {
506+ t .Fatalf ("buildTLSConfig: %v" , err )
507+ }
508+
509+ const apiPath = "/cryptos.fleet.v1.FleetService/"
510+ srv := httptest .NewUnstartedServer (newRootHandler (
511+ apiPath ,
512+ stubHandler (http .StatusOK , "api" ),
513+ stubHandler (http .StatusOK , "spa" ),
514+ authz .ClientCertMiddleware ,
515+ nil ,
516+ ))
517+ srv .EnableHTTP2 = true
518+ srv .TLS = tlsCfg
519+ srv .StartTLS ()
520+ t .Cleanup (srv .Close )
521+
522+ do := func (t * testing.T , c * http.Client , method , path string ) (int , bool ) {
523+ t .Helper ()
524+ var reused bool
525+ req , err := http .NewRequest (method , srv .URL + path , nil )
526+ if err != nil {
527+ t .Fatal (err )
528+ }
529+ resp , err := c .Do (connTracker (req , & reused ))
530+ if err != nil {
531+ t .Fatalf ("%s %s: %v" , method , path , err )
532+ }
533+ _ , _ = io .Copy (io .Discard , resp .Body )
534+ _ = resp .Body .Close ()
535+ if resp .ProtoMajor != 2 {
536+ t .Fatalf ("%s %s: proto = %s, want HTTP/2" , method , path , resp .Proto )
537+ }
538+ return resp .StatusCode , reused
539+ }
540+
541+ t .Run ("no certificate" , func (t * testing.T ) {
542+ c := srv .Client ()
543+ t .Cleanup (c .CloseIdleConnections )
544+
545+ if code , _ := do (t , c , http .MethodGet , "/" ); code != http .StatusOK {
546+ t .Fatalf ("GET / = %d, want 200" , code )
547+ }
548+ code , reused := do (t , c , http .MethodPost , apiPath + "WhoAmI" )
549+ if code != http .StatusUnauthorized {
550+ t .Fatalf ("API = %d, want 401" , code )
551+ }
552+ if ! reused {
553+ t .Fatal ("API call did not reuse the web connection; the test is not exercising #77" )
554+ }
555+ // The refused connection is gone: the next request handshakes again.
556+ code , reused = do (t , c , http .MethodPost , apiPath + "WhoAmI" )
557+ if code != http .StatusUnauthorized {
558+ t .Fatalf ("API retry = %d, want 401" , code )
559+ }
560+ if reused {
561+ t .Error ("API retry reused the certless connection, want a fresh handshake" )
562+ }
563+ })
564+
565+ t .Run ("with certificate" , func (t * testing.T ) {
566+ c := srv .Client ()
567+ tr := c .Transport .(* http.Transport ).Clone ()
568+ tr .TLSClientConfig .Certificates = []tls.Certificate {operatorLeaf (t , ca , caKey )}
569+ c = & http.Client {Transport : tr }
570+ t .Cleanup (c .CloseIdleConnections )
571+
572+ if code , _ := do (t , c , http .MethodGet , "/" ); code != http .StatusOK {
573+ t .Fatalf ("GET / = %d, want 200" , code )
574+ }
575+ // An authenticated connection is left alone and keeps being reused.
576+ for range 2 {
577+ code , reused := do (t , c , http .MethodPost , apiPath + "WhoAmI" )
578+ if code != http .StatusOK {
579+ t .Fatalf ("API = %d, want 200" , code )
580+ }
581+ if ! reused {
582+ t .Error ("authenticated API call did not reuse its connection" )
583+ }
584+ }
585+ })
586+ }
0 commit comments