From e764b0d96d334c44f8d8d7f9aa46f38931d2c54d Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:15:15 +0700 Subject: [PATCH] feat(gateway): enforce phase 3d tls trust --- cmd/internal/gatewaytls/tls.go | 206 ++++++++++++ cmd/internal/gatewaytls/tls_test.go | 474 ++++++++++++++++++++++++++++ cmd/verse-gateway/main.go | 40 +-- go.mod | 2 +- go.sum | 2 + 5 files changed, 694 insertions(+), 30 deletions(-) create mode 100644 cmd/internal/gatewaytls/tls.go create mode 100644 cmd/internal/gatewaytls/tls_test.go diff --git a/cmd/internal/gatewaytls/tls.go b/cmd/internal/gatewaytls/tls.go new file mode 100644 index 0000000..61a56b4 --- /dev/null +++ b/cmd/internal/gatewaytls/tls.go @@ -0,0 +1,206 @@ +package gatewaytls + +import ( + "bytes" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "net" + "net/url" + "os" + "slices" + "strings" + "time" +) + +type Material struct { + ServerTLS *tls.Config + ControlTLS *tls.Config + CertificateIdentity string +} + +func Load(certFile, keyFile, streamingCAFile, controlCAFile, gatewayID, publicIdentity string) (Material, error) { + certificate, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return Material{}, fmt.Errorf("load gateway certificate: %w", err) + } + if len(certificate.Certificate) == 0 { + return Material{}, errors.New("gateway certificate chain is empty") + } + leaf, err := x509.ParseCertificate(certificate.Certificate[0]) + if err != nil { + return Material{}, errors.New("parse gateway leaf certificate") + } + certificate.Leaf = leaf + if now := time.Now(); now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) { + return Material{}, errors.New("gateway leaf certificate is not currently valid") + } + if !hasUsage(leaf, x509.ExtKeyUsageServerAuth) || !hasUsage(leaf, x509.ExtKeyUsageClientAuth) { + return Material{}, errors.New("gateway leaf certificate requires ServerAuth and ClientAuth") + } + if !gatewayURIAllowed(leaf.URIs, gatewayID) { + return Material{}, errors.New("gateway leaf certificate URI identity mismatch") + } + if !validPublicDNSName(publicIdentity) || !slices.Contains(leaf.DNSNames, publicIdentity) || leaf.VerifyHostname(publicIdentity) != nil { + return Material{}, errors.New("gateway public identity must match a DNS SAN") + } + streamingCAs, err := loadCAPool(streamingCAFile) + if err != nil { + return Material{}, fmt.Errorf("load streaming CA: %w", err) + } + controlRoots, err := loadControlRootPool(controlCAFile) + if err != nil { + return Material{}, fmt.Errorf("load control root: %w", err) + } + digest := sha256.Sum256(leaf.Raw) + return Material{ + ServerTLS: &tls.Config{ + MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, + ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: streamingCAs, + }, + ControlTLS: &tls.Config{ + MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, RootCAs: controlRoots, + }, + CertificateIdentity: "sha256:" + hex.EncodeToString(digest[:]), + }, nil +} + +func loadCAPool(path string) (*x509.CertPool, error) { + encoded, err := os.ReadFile(path) + if err != nil { + return nil, err + } + pool := x509.NewCertPool() + count := 0 + for len(bytes.TrimSpace(encoded)) > 0 { + block, rest := pem.Decode(encoded) + if block == nil { + return nil, errors.New("CA PEM contains invalid data") + } + encoded = rest + if block.Type != "CERTIFICATE" { + return nil, errors.New("CA PEM contains a non-certificate block") + } + certificate, parseErr := x509.ParseCertificate(block.Bytes) + if parseErr != nil || !certificate.IsCA || certificate.KeyUsage&x509.KeyUsageCertSign == 0 { + return nil, errors.New("CA PEM contains a non-CA certificate") + } + pool.AddCert(certificate) + count++ + } + if count == 0 { + return nil, errors.New("CA PEM contains no certificate") + } + return pool, nil +} + +func loadControlRootPool(path string) (*x509.CertPool, error) { + encoded, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var certificates []*x509.Certificate + for len(bytes.TrimSpace(encoded)) > 0 { + block, rest := pem.Decode(encoded) + if block == nil { + return nil, errors.New("control trust PEM contains invalid data") + } + encoded = rest + if block.Type != "CERTIFICATE" { + return nil, errors.New("control trust PEM contains a non-certificate block") + } + certificate, parseErr := x509.ParseCertificate(block.Bytes) + if parseErr != nil { + return nil, errors.New("control trust PEM contains an invalid certificate") + } + certificates = append(certificates, certificate) + } + if len(certificates) == 0 { + return nil, errors.New("control trust PEM contains no certificate") + } + + pool := x509.NewCertPool() + allCAs := true + for _, certificate := range certificates { + if !certificate.IsCA || certificate.KeyUsage&x509.KeyUsageCertSign == 0 { + allCAs = false + break + } + } + if allCAs { + for _, certificate := range certificates { + pool.AddCert(certificate) + } + return pool, nil + } + + leaf := certificates[0] + if len(certificates) != 1 || leaf.IsCA || !hasUsage(leaf, x509.ExtKeyUsageServerAuth) { + return nil, errors.New("control trust PEM must contain CAs or one ServerAuth leaf") + } + pool.AddCert(leaf) + return pool, nil +} + +func hasUsage(certificate *x509.Certificate, wanted x509.ExtKeyUsage) bool { + for _, usage := range certificate.ExtKeyUsage { + if usage == wanted { + return true + } + } + return false +} + +func gatewayURIAllowed(uris []*url.URL, gatewayID string) bool { + if !validUUID(gatewayID) { + return false + } + prefix := "/gateway/" + gatewayID + "/credential/" + for _, uri := range uris { + if uri == nil || uri.Scheme != "spiffe" || uri.Host != "versevdi" || uri.User != nil || uri.Opaque != "" || uri.RawPath != "" || + uri.RawQuery != "" || uri.ForceQuery || uri.Fragment != "" || uri.RawFragment != "" || !strings.HasPrefix(uri.Path, prefix) { + continue + } + if validUUID(strings.TrimPrefix(uri.Path, prefix)) { + return true + } + } + return false +} + +func validPublicDNSName(name string) bool { + if len(name) == 0 || len(name) > 253 || net.ParseIP(name) != nil { + return false + } + for _, label := range strings.Split(name, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, character := range []byte(label) { + if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-') { + return false + } + } + } + return true +} + +func validUUID(value string) bool { + if len(value) != 36 || value != strings.ToLower(value) || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' { + return false + } + decoded, err := hex.DecodeString(strings.ReplaceAll(value, "-", "")) + if err != nil { + return false + } + for _, b := range decoded { + if b != 0 { + return true + } + } + return false +} diff --git a/cmd/internal/gatewaytls/tls_test.go b/cmd/internal/gatewaytls/tls_test.go new file mode 100644 index 0000000..b48aba7 --- /dev/null +++ b/cmd/internal/gatewaytls/tls_test.go @@ -0,0 +1,474 @@ +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 +} diff --git a/cmd/verse-gateway/main.go b/cmd/verse-gateway/main.go index a2e4fa6..c34b6e3 100644 --- a/cmd/verse-gateway/main.go +++ b/cmd/verse-gateway/main.go @@ -2,14 +2,11 @@ package main import ( "context" - "crypto/tls" - "crypto/x509" "errors" "flag" "fmt" "log" "net" - "os" "os/signal" "strings" "syscall" @@ -17,6 +14,7 @@ import ( "net/http" + "git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/cmd/internal/gatewaytls" "git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/gateway" protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol" ) @@ -28,20 +26,20 @@ func main() { } func run() error { - var listen, advertiseAddress, controlPlane, certFile, keyFile, clientCAFile string - var gatewayID, instanceIdentity, certificateIdentity, publicIdentity string + var listen, advertiseAddress, controlPlane, certFile, keyFile, streamingCAFile, controlCAFile string + var gatewayID, instanceIdentity, publicIdentity string flag.StringVar(&listen, "listen", "0.0.0.0:443", "gateway QUIC listen address") flag.StringVar(&advertiseAddress, "advertise-address", "", "client-visible gateway address host:port") flag.StringVar(&controlPlane, "control-plane", "", "Connection Server HTTPS base URL") flag.StringVar(&certFile, "cert", "", "gateway certificate PEM") flag.StringVar(&keyFile, "key", "", "gateway private key PEM") - flag.StringVar(&clientCAFile, "client-ca", "", "Connection Server/client CA PEM") + flag.StringVar(&streamingCAFile, "streaming-ca", "", "CA PEM for QUIC streaming clients") + flag.StringVar(&controlCAFile, "control-ca", "", "CA bundle or exact Server leaf PEM") flag.StringVar(&gatewayID, "gateway-id", "", "stable gateway identifier") flag.StringVar(&instanceIdentity, "instance-identity", "", "gateway instance identity") - flag.StringVar(&certificateIdentity, "certificate-identity", "", "gateway certificate identity") - flag.StringVar(&publicIdentity, "public-identity", "gateway", "gateway public identity") + flag.StringVar(&publicIdentity, "public-identity", "", "gateway DNS identity from the certificate SAN") flag.Parse() - for name, value := range map[string]string{"control-plane": controlPlane, "advertise-address": advertiseAddress, "cert": certFile, "key": keyFile, "client-ca": clientCAFile, "gateway-id": gatewayID, "instance-identity": instanceIdentity, "certificate-identity": certificateIdentity} { + for name, value := range map[string]string{"control-plane": controlPlane, "advertise-address": advertiseAddress, "cert": certFile, "key": keyFile, "streaming-ca": streamingCAFile, "control-ca": controlCAFile, "gateway-id": gatewayID, "instance-identity": instanceIdentity, "public-identity": publicIdentity} { if value == "" { return fmt.Errorf("-%s is required", name) } @@ -49,20 +47,20 @@ func run() error { if err := validateAdvertisedAddress(advertiseAddress); err != nil { return err } - serverTLS, clientTLS, err := loadTLS(certFile, keyFile, clientCAFile) + tlsMaterial, err := gatewaytls.Load(certFile, keyFile, streamingCAFile, controlCAFile, gatewayID, publicIdentity) if err != nil { return err } - transport := &http.Transport{TLSClientConfig: clientTLS} + transport := &http.Transport{TLSClientConfig: tlsMaterial.ControlTLS} controlPlaneClient := gateway.NewControlPlaneClient(controlPlane, &http.Client{Transport: transport, Timeout: 5 * time.Second}) provider := gateway.NewApolloAdapter(gateway.NewNativeApolloBackend(), gateway.ProviderIdentity{}) capabilities := gateway.DefaultCapabilities() features := gateway.DefaultFeatures() - server, err := gateway.NewServer(gateway.ServerConfig{ListenAddress: listen, TLSConfig: serverTLS, GatewayID: gatewayID, Features: features, Capabilities: capabilities, ProviderCapabilities: capabilities, Admission: controlPlaneClient, ProviderStateReporter: controlPlaneClient, ClipboardAuditReporter: controlPlaneClient, Provider: provider, PacerKbps: 100000}) + server, err := gateway.NewServer(gateway.ServerConfig{ListenAddress: listen, TLSConfig: tlsMaterial.ServerTLS, GatewayID: gatewayID, Features: features, Capabilities: capabilities, ProviderCapabilities: capabilities, Admission: controlPlaneClient, ProviderStateReporter: controlPlaneClient, ClipboardAuditReporter: controlPlaneClient, Provider: provider, PacerKbps: 100000}) if err != nil { return err } - registration := protocol.GatewayRegistration{Version: "1", GatewayID: gatewayID, InstanceIdentity: instanceIdentity, CertificateIdentity: certificateIdentity, PublicIdentity: publicIdentity, Address: advertiseAddress, ProviderIdentity: "server-derived", ProtocolMinVersion: 1, ProtocolMaxVersion: 1, ConnectionCapacity: 8, BandwidthCapacityKbps: 100000, Features: features, Capabilities: capabilities} + registration := protocol.GatewayRegistration{Version: "1", GatewayID: gatewayID, InstanceIdentity: instanceIdentity, CertificateIdentity: tlsMaterial.CertificateIdentity, PublicIdentity: publicIdentity, Address: advertiseAddress, ProviderIdentity: "server-derived", ProtocolMinVersion: 1, ProtocolMaxVersion: 1, ConnectionCapacity: 8, BandwidthCapacityKbps: 100000, Features: features, Capabilities: capabilities} if _, err := controlPlaneClient.Register(context.Background(), registration); err != nil { _ = server.Close() return err @@ -183,22 +181,6 @@ func providerStateName(value uint64) string { } } -func loadTLS(certFile, keyFile, clientCAFile string) (*tls.Config, *tls.Config, error) { - certificate, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return nil, nil, err - } - caBytes, err := os.ReadFile(clientCAFile) - if err != nil { - return nil, nil, err - } - clientCAs := x509.NewCertPool() - if !clientCAs.AppendCertsFromPEM(caBytes) { - return nil, nil, errors.New("client CA PEM contains no certificate") - } - return &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs}, &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, RootCAs: clientCAs}, nil -} - func parseProviderIdentity(value string) (gateway.ProviderIdentity, error) { uniqueID, fingerprint, ok := strings.Cut(value, "#") if !ok || uniqueID == "" || fingerprint == "" { diff --git a/go.mod b/go.mod index 0a6ccf3..4742bd7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane go 1.26.5 require ( - git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.1 + git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.2 github.com/quic-go/quic-go v0.61.0 ) diff --git a/go.sum b/go.sum index b52811b..a1ba0e7 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.10 git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.10/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc= git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.1 h1:uCH+etSQtzqfhhRP3dWdp6aIDTU55G7r1UEOAJfWsGw= git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.1/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc= +git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.2 h1:UdkgLXQ2fWFCTGcMblZHXhvQbDx2wfTy4eHyEvav72U= +git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.2/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=