package gatewaytls import ( "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/hex" "encoding/pem" "math/big" "net" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" ) const ( testGatewayID = "7d0d1308-5e50-45ef-984c-4508ca899f5a" testCredentialID = "ba4d93f8-d692-4210-97db-5a561bfb32d3" ) type testCA struct { certificate *x509.Certificate key *ecdsa.PrivateKey der []byte } func TestLoadSeparatesTrustPoolsAndDerivesGatewayIdentity(t *testing.T) { streamingCA := newTestCA(t, "streaming-ca") controlCA := newTestCA(t, "control-ca") identityCA := newTestCA(t, "identity-ca") uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID) leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}) certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key) streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der) controlFile := writeCertificate(t, "control-ca.pem", controlCA.der) material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example") if err != nil { t.Fatalf("Load() error = %v", err) } digest := sha256.Sum256(leafDER) if material.CertificateIdentity != "sha256:"+hex.EncodeToString(digest[:]) { t.Fatalf("certificate identity = %q", material.CertificateIdentity) } if material.ServerTLS.MinVersion != tls.VersionTLS13 || material.ControlTLS.MinVersion != tls.VersionTLS13 || material.ServerTLS.ClientAuth != tls.RequireAndVerifyClientCert || material.ServerTLS.ClientCAs == nil || material.ControlTLS.RootCAs == nil || len(material.ServerTLS.Certificates) != 1 || len(material.ControlTLS.Certificates) != 1 { t.Fatalf("TLS material = server:%#v control:%#v", material.ServerTLS, material.ControlTLS) } if got := material.ServerTLS.ClientCAs.Subjects(); len(got) != 1 || string(got[0]) != string(streamingCA.certificate.RawSubject) { t.Fatalf("streaming ClientCAs = %x", got) } if got := material.ControlTLS.RootCAs.Subjects(); len(got) != 1 || string(got[0]) != string(controlCA.certificate.RawSubject) { t.Fatalf("control RootCAs = %x", got) } if string(material.ServerTLS.Certificates[0].Certificate[0]) != string(material.ControlTLS.Certificates[0].Certificate[0]) { t.Fatal("server and control TLS did not use the same gateway leaf") } trustedControlLeaf, _ := newLeaf(t, controlCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}) parsedTrusted, _ := x509.ParseCertificate(trustedControlLeaf) if _, err := parsedTrusted.Verify(x509.VerifyOptions{Roots: material.ControlTLS.RootCAs, DNSName: "control.example"}); err != nil { t.Fatalf("control CA did not verify control server: %v", err) } wrongControlLeaf, _ := newLeaf(t, streamingCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}) parsedWrong, _ := x509.ParseCertificate(wrongControlLeaf) if _, err := parsedWrong.Verify(x509.VerifyOptions{Roots: material.ControlTLS.RootCAs, DNSName: "control.example"}); err == nil { t.Fatal("control RootCAs trusted the streaming CA") } } func TestLoadTrustsExactSelfSignedControlLeaf(t *testing.T) { streamingCA := newTestCA(t, "streaming-ca") identityCA := newTestCA(t, "identity-ca") uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID) gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}) certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey) streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der) controlLeaf, controlKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.1")) controlFile := writeCertificate(t, "control-leaf.pem", controlLeaf) material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example") if err != nil { t.Fatalf("Load() error = %v", err) } if material.ControlTLS.MinVersion != tls.VersionTLS13 { t.Fatalf("ControlTLS.MinVersion = %d, want TLS 1.3", material.ControlTLS.MinVersion) } clientCAs := x509.NewCertPool() clientCAs.AddCert(identityCA.certificate) trusted := newControlServer(t, controlLeaf, controlKey, clientCAs) defer trusted.Close() response, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(trusted.URL) if err != nil { t.Fatalf("pinned self-signed control leaf handshake: %v", err) } response.Body.Close() if response.StatusCode != http.StatusNoContent { t.Fatalf("trusted control response status = %d", response.StatusCode) } wrongLeaf, wrongKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.1")) wrong := newControlServer(t, wrongLeaf, wrongKey, clientCAs) defer wrong.Close() if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(wrong.URL); err == nil { t.Fatal("different self-signed leaf with the same SAN completed handshake") } mismatchedLeaf, mismatchedKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.2")) mismatchedFile := writeCertificate(t, "mismatched-control-leaf.pem", mismatchedLeaf) mismatchedMaterial, err := Load(certFile, keyFile, streamingFile, mismatchedFile, testGatewayID, "gateway.example") if err != nil { t.Fatalf("Load() mismatched control leaf error = %v", err) } mismatched := newControlServer(t, mismatchedLeaf, mismatchedKey, clientCAs) defer mismatched.Close() if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: mismatchedMaterial.ControlTLS}}).Get(mismatched.URL); err == nil { t.Fatal("control leaf without the request hostname/SAN completed handshake") } if _, err := Load(certFile, keyFile, controlFile, controlFile, testGatewayID, "gateway.example"); err == nil { t.Fatal("Load() accepted a non-CA leaf as streaming trust") } } func TestLoadTrustsExactCAIssuedControlLeaf(t *testing.T) { streamingCA := newTestCA(t, "streaming-ca") controlCA := newTestCA(t, "control-ca") identityCA := newTestCA(t, "identity-ca") uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID) gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}) certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey) streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der) activeLeaf, activeKey := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1")) controlFile := writeCertificate(t, "control-leaf.pem", activeLeaf) material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example") if err != nil { t.Fatalf("Load() error = %v", err) } clientCAs := x509.NewCertPool() clientCAs.AddCert(identityCA.certificate) trusted := newControlServer(t, activeLeaf, activeKey, clientCAs, controlCA.der) defer trusted.Close() response, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(trusted.URL) if err != nil { t.Fatalf("pinned CA-issued control leaf handshake: %v", err) } response.Body.Close() if response.TLS == nil || response.TLS.Version != tls.VersionTLS13 { t.Fatalf("pinned control leaf TLS version = %v, want TLS 1.3", response.TLS) } if response.StatusCode != http.StatusNoContent { t.Fatalf("trusted control response status = %d", response.StatusCode) } siblingLeaf, siblingKey := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1")) sibling := newControlServer(t, siblingLeaf, siblingKey, clientCAs, controlCA.der) defer sibling.Close() if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(sibling.URL); err == nil { t.Fatal("sibling control leaf from the same issuer and SAN completed handshake") } } func TestLoadRejectsControlLeafBundlesAndNonServerAuth(t *testing.T) { streamingCA := newTestCA(t, "streaming-ca") controlCA := newTestCA(t, "control-ca") identityCA := newTestCA(t, "identity-ca") uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID) gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}) certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey) streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der) serverLeaf, _ := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1")) nonServerLeaf, _ := newLeaf(t, controlCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}) for _, test := range []struct { name string certificates [][]byte }{ {"leaf and issuer chain", [][]byte{serverLeaf, controlCA.der}}, {"non ServerAuth leaf", [][]byte{nonServerLeaf}}, } { t.Run(test.name, func(t *testing.T) { controlFile := writeCertificateBundle(t, "control.pem", test.certificates...) if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example"); err == nil { t.Fatal("Load() accepted invalid control trust material") } }) } } func TestLoadRejectsWrongPublicNameURIAndLeafUsage(t *testing.T) { streamingCA := newTestCA(t, "streaming-ca") controlCA := newTestCA(t, "control-ca") identityCA := newTestCA(t, "identity-ca") streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der) controlFile := writeCertificate(t, "control-ca.pem", controlCA.der) tests := []struct { name string uriGatewayID string publicIdentity string leafDNSName string usages []x509.ExtKeyUsage }{ {"wrong public name", testGatewayID, "other.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}, {"noncanonical public identity", testGatewayID, " gateway.example ", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}, {"wildcard is not an exact public identity", testGatewayID, "gateway.example", "*.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}, {"wrong URI gateway", "c1f5d25a-47c6-44ed-b9be-e0e8e87d4ae2", "gateway.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}, {"missing client auth", testGatewayID, "gateway.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { uri := mustURL(t, "spiffe://versevdi/gateway/"+test.uriGatewayID+"/credential/"+testCredentialID) leafDER, key := newLeaf(t, identityCA, test.leafDNSName, []*url.URL{uri}, test.usages) certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key) if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, test.publicIdentity); err == nil { t.Fatal("Load() accepted invalid gateway certificate identity") } }) } } func TestLoadRejectsNonCanonicalPublicDNSIdentity(t *testing.T) { streamingCA := newTestCA(t, "streaming-ca") controlCA := newTestCA(t, "control-ca") identityCA := newTestCA(t, "identity-ca") streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der) controlFile := writeCertificate(t, "control-ca.pem", controlCA.der) uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID) tooLongLabel := strings.Repeat("a", 64) + ".example" tooLongName := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 62) for _, test := range []struct { name string leafDNSName string publicIdentity string }{ {"uppercase exact SAN", "Gateway.example", "Gateway.example"}, {"empty label", "gateway..example", "gateway..example"}, {"leading hyphen", "-gateway.example", "-gateway.example"}, {"trailing hyphen", "gateway-.example", "gateway-.example"}, {"wildcard", "*.example", "*.example"}, {"label exceeds 63 bytes", tooLongLabel, tooLongLabel}, {"name exceeds 253 bytes", tooLongName, tooLongName}, {"IP address", "127.0.0.1", "127.0.0.1"}, {"port", "gateway.example:443", "gateway.example:443"}, {"path", "gateway.example/path", "gateway.example/path"}, {"whitespace", "gateway .example", "gateway .example"}, {"trailing dot", "gateway.example.", "gateway.example."}, {"Unicode", "gateway.example", "gäteway.example"}, } { t.Run(test.name, func(t *testing.T) { leafDER, key := newLeaf(t, identityCA, test.leafDNSName, []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}) certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key) if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, test.publicIdentity); err == nil { t.Fatal("Load() accepted a noncanonical public DNS identity") } }) } } func TestLoadAcceptsCanonicalPublicDNSIdentity(t *testing.T) { streamingCA := newTestCA(t, "streaming-ca") controlCA := newTestCA(t, "control-ca") identityCA := newTestCA(t, "identity-ca") streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der) controlFile := writeCertificate(t, "control-ca.pem", controlCA.der) uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID) for _, publicIdentity := range []string{"gateway.example", "gateway"} { t.Run(publicIdentity, func(t *testing.T) { leafDER, key := newLeaf(t, identityCA, publicIdentity, []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}) certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key) if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, publicIdentity); err != nil { t.Fatalf("Load() error = %v", err) } }) } } func TestLoadRejectsLegacyAndNoncanonicalGatewayURIs(t *testing.T) { streamingCA := newTestCA(t, "streaming-ca") controlCA := newTestCA(t, "control-ca") identityCA := newTestCA(t, "identity-ca") streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der) controlFile := writeCertificate(t, "control-ca.pem", controlCA.der) canonical := "spiffe://versevdi/gateway/" + testGatewayID + "/credential/" + testCredentialID for _, test := range []struct { name string uri string }{ {"legacy URN", "urn:versevdi:gateway:" + testGatewayID + ":credential:" + testCredentialID}, {"opaque URI", "spiffe:gateway/" + testGatewayID + "/credential/" + testCredentialID}, {"leading path slash", "spiffe://versevdi//gateway/" + testGatewayID + "/credential/" + testCredentialID}, {"trailing path slash", canonical + "/"}, {"percent encoded credential", "spiffe://versevdi/gateway/" + testGatewayID + "/credential/%62a4d93f8-d692-4210-97db-5a561bfb32d3"}, {"force query", canonical + "?"}, {"query", canonical + "?version=1"}, {"fragment", canonical + "#fragment"}, {"user", "spiffe://gateway@versevdi/gateway/" + testGatewayID + "/credential/" + testCredentialID}, } { t.Run(test.name, func(t *testing.T) { uri := mustURL(t, test.uri) leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}) certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key) if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example"); err == nil { t.Fatal("Load() accepted a legacy or noncanonical gateway URI") } }) } t.Run("uppercase UUIDs", func(t *testing.T) { gatewayID := strings.ToUpper(testGatewayID) uri := mustURL(t, "spiffe://versevdi/gateway/"+gatewayID+"/credential/"+strings.ToUpper(testCredentialID)) leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}) certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key) if _, err := Load(certFile, keyFile, streamingFile, controlFile, gatewayID, "gateway.example"); err == nil { t.Fatal("Load() accepted noncanonical uppercase UUIDs") } }) } func newTestCA(t *testing.T, commonName string) testCA { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } template := &x509.Certificate{ SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: commonName}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, } der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { t.Fatal(err) } certificate, err := x509.ParseCertificate(der) if err != nil { t.Fatal(err) } return testCA{certificate: certificate, key: key, der: der} } func newLeaf(t *testing.T, ca testCA, dnsName string, uris []*url.URL, usages []x509.ExtKeyUsage) ([]byte, ed25519.PrivateKey) { t.Helper() publicKey, key, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } template := &x509.Certificate{ SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: dnsName}, DNSNames: []string{dnsName}, URIs: uris, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: usages, } der, err := x509.CreateCertificate(rand.Reader, template, ca.certificate, publicKey, ca.key) if err != nil { t.Fatal(err) } return der, key } func newSelfSignedServerLeaf(t *testing.T, ip net.IP) ([]byte, *ecdsa.PrivateKey) { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } template := &x509.Certificate{ SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "control.example"}, IPAddresses: []net.IP{ip}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, } der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { t.Fatal(err) } return der, key } func newCAIssuedControlLeaf(t *testing.T, ca testCA, ip net.IP) ([]byte, ed25519.PrivateKey) { t.Helper() publicKey, key, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } template := &x509.Certificate{ SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "control.example"}, IPAddresses: []net.IP{ip}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, } der, err := x509.CreateCertificate(rand.Reader, template, ca.certificate, publicKey, ca.key) if err != nil { t.Fatal(err) } return der, key } func newControlServer(t *testing.T, leaf []byte, key any, clientCAs *x509.CertPool, chain ...[]byte) *httptest.Server { t.Helper() server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if request.TLS == nil || len(request.TLS.PeerCertificates) == 0 { http.Error(response, "mTLS required", http.StatusUnauthorized) return } response.WriteHeader(http.StatusNoContent) })) certificates := append([][]byte{leaf}, chain...) server.TLS = &tls.Config{ MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{{Certificate: certificates, PrivateKey: key}}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs, } server.StartTLS() return server } func writeKeyPair(t *testing.T, leafDER, issuerDER []byte, key ed25519.PrivateKey) (string, string) { t.Helper() directory := t.TempDir() certFile := filepath.Join(directory, "gateway.pem") keyFile := filepath.Join(directory, "gateway-key.pem") certificatePEM := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuerDER})...) privateDER, err := x509.MarshalPKCS8PrivateKey(key) if err != nil { t.Fatal(err) } if err := os.WriteFile(certFile, certificatePEM, 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}), 0o600); err != nil { t.Fatal(err) } return certFile, keyFile } func writeCertificate(t *testing.T, name string, der []byte) string { return writeCertificateBundle(t, name, der) } func writeCertificateBundle(t *testing.T, name string, certificates ...[]byte) string { t.Helper() path := filepath.Join(t.TempDir(), name) var encoded []byte for _, certificate := range certificates { encoded = append(encoded, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate})...) } if err := os.WriteFile(path, encoded, 0o600); err != nil { t.Fatal(err) } return path } func mustURL(t *testing.T, value string) *url.URL { t.Helper() parsed, err := url.Parse(value) if err != nil { t.Fatal(err) } return parsed }