diff --git a/go/cmd/vttestserver/vttestserver_test.go b/go/cmd/vttestserver/vttestserver_test.go index 226d66305be..bf8d5fd8f5e 100644 --- a/go/cmd/vttestserver/vttestserver_test.go +++ b/go/cmd/vttestserver/vttestserver_test.go @@ -21,6 +21,7 @@ import ( "fmt" "io" "math/rand" + "net/url" "os" "os/exec" "path" @@ -307,6 +308,96 @@ func TestMtlsAuthUnauthorizedFails(t *testing.T) { assert.Contains(t, err.Error(), "code = Unauthenticated desc = client certificate not authorized") } +func TestSPIFFEAuth(t *testing.T) { + args := os.Args + conf := config + defer resetFlags(args, conf) + + // Our test root. + root := t.TempDir() + + // Create the certs and configs. + tlstest.CreateCA(root) + caCert := path.Join(root, "ca-cert.pem") + + tlstest.CreateSignedCert(root, tlstest.CA, "01", "vtctld", "vtctld.example.com") + cert := path.Join(root, "vtctld-cert.pem") + key := path.Join(root, "vtctld-key.pem") + + clientSPIFFEId := &url.URL{ + Scheme: "spiffe", + Host: "auth.example.com", + Path: "/client", + } + tlstest.CreateSignedSvid(root, tlstest.CA, "02", "client", "ClientApp", clientSPIFFEId) + clientCert := path.Join(root, "client-cert.pem") + clientKey := path.Join(root, "client-key.pem") + + // When cluster starts it will apply SQL and VSchema migrations in the configured schema_dir folder + // With SPIFFE authorization enabled, the certificate/SVID's SPIFFE ID must be in one of the allowed trust domains + cluster, err := startCluster( + "--grpc_auth_mode=spiffe", + fmt.Sprintf("--grpc_key=%s", key), + fmt.Sprintf("--grpc_cert=%s", cert), + fmt.Sprintf("--grpc_ca=%s", caCert), + fmt.Sprintf("--vtctld_grpc_key=%s", clientKey), + fmt.Sprintf("--vtctld_grpc_cert=%s", clientCert), + fmt.Sprintf("--vtctld_grpc_ca=%s", caCert), + fmt.Sprintf("--grpc_auth_spiffe_allowed_trust_domains=%s", "auth.example.com")) + assert.NoError(t, err) + defer func() { + cluster.PersistentMode = false // Cleanup the tmpdir as we're done + cluster.TearDown() + }() + + // startCluster will apply vschema migrations using vtctl grpc and the clientCert. + assertColumnVindex(t, cluster, columnVindex{keyspace: "test_keyspace", table: "test_table", vindex: "my_vdx", vindexType: "hash", column: "id"}) + assertColumnVindex(t, cluster, columnVindex{keyspace: "app_customer", table: "customers", vindex: "hash", vindexType: "hash", column: "id"}) +} + +func TestSPIFFEAuthUnauthorizedFails(t *testing.T) { + args := os.Args + conf := config + defer resetFlags(args, conf) + + // Our test root. + root := t.TempDir() + + // Create the certs and configs. + tlstest.CreateCA(root) + caCert := path.Join(root, "ca-cert.pem") + + tlstest.CreateSignedCert(root, tlstest.CA, "01", "vtctld", "vtctld.example.com") + cert := path.Join(root, "vtctld-cert.pem") + key := path.Join(root, "vtctld-key.pem") + + clientSPIFFEId := &url.URL{ + Scheme: "spiffe", + Host: "some.otherdomain.com", + Path: "/anotherclient", + } + tlstest.CreateSignedSvid(root, tlstest.CA, "02", "client", "AnotherApp", clientSPIFFEId) + clientCert := path.Join(root, "client-cert.pem") + clientKey := path.Join(root, "client-key.pem") + + // When cluster starts it will apply SQL and VSchema migrations in the configured schema_dir folder + // Force SPIFFE authorization failure by providing a client certificate with SPIFFE ID for a domain + // that is not in the list of allowed trust domains + cluster, err := startCluster( + "--grpc_auth_mode=spiffe", + fmt.Sprintf("--grpc_key=%s", key), + fmt.Sprintf("--grpc_cert=%s", cert), + fmt.Sprintf("--grpc_ca=%s", caCert), + fmt.Sprintf("--vtctld_grpc_key=%s", clientKey), + fmt.Sprintf("--vtctld_grpc_cert=%s", clientCert), + fmt.Sprintf("--vtctld_grpc_ca=%s", caCert), + fmt.Sprintf("--grpc_auth_spiffe_allowed_trust_domains=%s", "auth.example.com")) + defer cluster.TearDown() + + assert.Error(t, err) + assert.Contains(t, err.Error(), "code = Unauthenticated desc = client certificate not authorized") +} + func startPersistentCluster(dir string, flags ...string) (vttest.LocalCluster, error) { flags = append(flags, []string{ "--persistent_mode", diff --git a/go/flags/endtoend/mysqlctld.txt b/go/flags/endtoend/mysqlctld.txt index 6fbbd059492..078e9b61ecf 100644 --- a/go/flags/endtoend/mysqlctld.txt +++ b/go/flags/endtoend/mysqlctld.txt @@ -42,6 +42,7 @@ Usage of mysqlctld: --dba_pool_size int Size of the connection pool for dba connections (default 20) --grpc_auth_mode string Which auth plugin implementation to use (eg: static) --grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon). + --grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma). --grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server. --grpc_auth_static_password_file string JSON File to read the users/passwords from. --grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check diff --git a/go/flags/endtoend/vtctld.txt b/go/flags/endtoend/vtctld.txt index 2cf009be350..c63693005e3 100644 --- a/go/flags/endtoend/vtctld.txt +++ b/go/flags/endtoend/vtctld.txt @@ -57,6 +57,7 @@ Flags: --gcs_backup_storage_root string Root prefix for all backup-related object names. --grpc_auth_mode string Which auth plugin implementation to use (eg: static) --grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon). + --grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma). --grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server. --grpc_auth_static_password_file string JSON File to read the users/passwords from. --grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check diff --git a/go/flags/endtoend/vtgate.txt b/go/flags/endtoend/vtgate.txt index 89f6544ca8f..41e92c808c1 100644 --- a/go/flags/endtoend/vtgate.txt +++ b/go/flags/endtoend/vtgate.txt @@ -67,6 +67,7 @@ Flags: --grpc-use-static-authentication-callerid If set, will set the immediate caller id to the username authenticated by the static auth plugin. --grpc_auth_mode string Which auth plugin implementation to use (eg: static) --grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon). + --grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma). --grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server. --grpc_auth_static_password_file string JSON File to read the users/passwords from. --grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check diff --git a/go/flags/endtoend/vttablet.txt b/go/flags/endtoend/vttablet.txt index 98ea41a5f8e..8b39bbe14be 100644 --- a/go/flags/endtoend/vttablet.txt +++ b/go/flags/endtoend/vttablet.txt @@ -168,6 +168,7 @@ Flags: --gh-ost-path string override default gh-ost binary full path --grpc_auth_mode string Which auth plugin implementation to use (eg: static) --grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon). + --grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma). --grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server. --grpc_auth_static_password_file string JSON File to read the users/passwords from. --grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check diff --git a/go/flags/endtoend/vttestserver.txt b/go/flags/endtoend/vttestserver.txt index 5849f0c1e81..791c2bdfd8c 100644 --- a/go/flags/endtoend/vttestserver.txt +++ b/go/flags/endtoend/vttestserver.txt @@ -40,6 +40,7 @@ Usage of vttestserver: --foreign_key_mode string This is to provide how to handle foreign key constraint in create/alter table. Valid values are: allow, disallow (default "allow") --grpc_auth_mode string Which auth plugin implementation to use (eg: static) --grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon). + --grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma). --grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server. --grpc_auth_static_password_file string JSON File to read the users/passwords from. --grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check diff --git a/go/vt/servenv/grpc_server.go b/go/vt/servenv/grpc_server.go index bd79aed8108..731a521e6d7 100644 --- a/go/vt/servenv/grpc_server.go +++ b/go/vt/servenv/grpc_server.go @@ -55,8 +55,8 @@ import ( // Note servenv.GRPCServer can only be used in servenv.OnRun, // and not before, as it is initialized right before calling OnRun. var ( - // gRPCAuth specifies which auth plugin to use. Currently only "static" and - // "mtls" are supported. + // gRPCAuth specifies which auth plugin to use. Currently only "static", + // "mtls", and "spiffe" are supported. // // To expose this flag, call RegisterGRPCAuthServerFlags before ParseFlags. gRPCAuth string diff --git a/go/vt/servenv/grpc_server_auth_spiffe.go b/go/vt/servenv/grpc_server_auth_spiffe.go new file mode 100644 index 00000000000..773fc69cc15 --- /dev/null +++ b/go/vt/servenv/grpc_server_auth_spiffe.go @@ -0,0 +1,160 @@ +/* +Copyright 2023 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package servenv + +import ( + "context" + "crypto/x509" + "net/url" + "strings" + + "github.com/spf13/pflag" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + + "vitess.io/vitess/go/vt/log" +) + +var ( + // spiffeTrustDomains list of allowed SPIFFE Trust Domains for client SVIDs during authorization + spiffeTrustDomains string + // SPIFFEAuthPlugin implements AuthPlugin interface + _ Authenticator = (*SPIFFEAuthPlugin)(nil) +) + +// The datatype for spiffe auth Context keys +type spiffeIdKey int + +const ( + // Internal Context key for the authenticated SPIFFE ID + spiffeId spiffeIdKey = 0 +) + +func registerGRPCServerAuthSPIFFEFlags(fs *pflag.FlagSet) { + fs.StringVar(&spiffeTrustDomains, "grpc_auth_spiffe_allowed_trust_domains", spiffeTrustDomains, "List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).") +} + +// SPIFFEAuthPlugin implements X.509-based SVID for SPIFFE authentication for grpc. It contains an array of trust domains +// that will be authorized to connect to the grpc server. +type SPIFFEAuthPlugin struct { + spiffeTrustDomains []string +} + +// Authenticate implements Authenticator interface. This method will be used inside a middleware in grpc_server to authenticate +// incoming requests. +func (spa *SPIFFEAuthPlugin) Authenticate(ctx context.Context, fullMethod string) (context.Context, error) { + p, ok := peer.FromContext(ctx) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "no peer connection info") + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "not connected via TLS") + } + + cert := tlsInfo.State.PeerCertificates[0] // Only check the leaf certificate + spiffeIdUrl, ok := validateSVIDCert(cert, spa.spiffeTrustDomains) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "client certificate not authorized") + } + + log.Infof("SPIFFE auth plugin has authenticated client with SPIFFE ID %v", spiffeId) + return newSPIFFEAuthContext(ctx, spiffeIdUrl), nil +} + +// Validates the given certificate as a valid SVID leaf certificate based on +// https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md#4-constraints-and-usage +// and https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md#5-validation +func validateSVIDCert(cert *x509.Certificate, trustedDomains []string) (*url.URL, bool) { + issuedForTrustedDomain := false + + // Leaf SVIDs should have exactly one URI SAN + if len(cert.URIs) != 1 { + return nil, false + } + + if cert.URIs[0].Scheme != "spiffe" { + return nil, false + } + + for _, trustDomain := range trustedDomains { + if cert.URIs[0].Hostname() == trustDomain { + issuedForTrustedDomain = true + break + } + } + + if !issuedForTrustedDomain { + return nil, false + } + + // Leaf SVIDs should not be CA certs + if cert.IsCA { + return nil, false + } + + // Leaf SVIDs should not have CA usage + if cert.KeyUsage&x509.KeyUsageCertSign != 0 { + return nil, false + } + + // Leaf SVIDs should not have CRL signing usage + if cert.KeyUsage&x509.KeyUsageCRLSign != 0 { + return nil, false + } + + // Leaf SVIDs must have Digital Signature usage + if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return nil, false + } + + return cert.URIs[0], true +} + +func newSPIFFEAuthContext(ctx context.Context, foundSPIFFEId *url.URL) context.Context { + return context.WithValue(ctx, spiffeId, foundSPIFFEId) +} + +func spiffeAuthPluginInitializer() (Authenticator, error) { + spiffeAuthPlugin := &SPIFFEAuthPlugin{ + spiffeTrustDomains: strings.Split(spiffeTrustDomains, ","), + } + log.Infof("SPIFFE auth plugin has initialized successfully with allowed trust domains of %v", spiffeTrustDomains) + return spiffeAuthPlugin, nil +} + +// SPIFFEIdFromContext returns the SPIFFE ID authenticated by the spiffe auth plugin and stored in the Context, if any +func SPIFFEIdFromContext(ctx context.Context) *url.URL { + spiffeId, ok := ctx.Value(spiffeId).(*url.URL) + if ok { + return spiffeId + } + return nil +} + +// SPIFFETrustDomains returns the value of the +// `--grpc_auth_spiffe_allowed_trust_domains` flag. +func SPIFFETrustDomains() string { + return spiffeTrustDomains +} + +func init() { + RegisterAuthPlugin("spiffe", spiffeAuthPluginInitializer) + grpcAuthServerFlagHooks = append(grpcAuthServerFlagHooks, registerGRPCServerAuthSPIFFEFlags) +} diff --git a/go/vt/tlstest/tlstest.go b/go/vt/tlstest/tlstest.go index ae560115e8d..389a9c94c3a 100644 --- a/go/vt/tlstest/tlstest.go +++ b/go/vt/tlstest/tlstest.go @@ -31,6 +31,7 @@ import ( "fmt" "math/big" "net" + "net/url" "os" "path" "strconv" @@ -160,6 +161,53 @@ func signCert(parent *x509.Certificate, parentPriv crypto.PrivateKey, certPub cr return x509.ParseCertificate(certificate) } +// signSvid is a helper function to sign a SVID (SPIFFE Verifiable Identity Document) +func signSvid(parent *x509.Certificate, parentPriv crypto.PrivateKey, certPub crypto.PublicKey, commonName string, serial int64, ca bool, spiffeId *url.URL) (*x509.Certificate, error) { + keyUsage := x509.KeyUsageDigitalSignature + var extKeyUsage []x509.ExtKeyUsage + var dnsNames []string + var ipAddresses []net.IP + var spiffeURIs []*url.URL + + if ca { + keyUsage = keyUsage | x509.KeyUsageCRLSign | x509.KeyUsageCertSign + } else { + extKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth} + dnsNames = []string{"localhost", commonName} + if spiffeId.String() != "" { + spiffeURIs = append(spiffeURIs, spiffeId) + } + ipAddresses = []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")} + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(serial), + Subject: pkix.Name{ + CommonName: commonName, + }, + NotBefore: time.Now().Add(-30 * time.Second), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: keyUsage, + ExtKeyUsage: extKeyUsage, + BasicConstraintsValid: true, + IsCA: ca, + DNSNames: dnsNames, + IPAddresses: ipAddresses, + URIs: spiffeURIs, + } + + // No parent defined means we create a self signed one. + if parent == nil { + parent = &template + } + + certificate, err := x509.CreateCertificate(rand.Reader, &template, parent, certPub, parentPriv) + if err != nil { + return nil, err + } + return x509.ParseCertificate(certificate) +} + // CreateCA creates the toplevel 'ca' certificate and key, and places it // in the provided directory. Temporary files are also created in that // directory. @@ -275,6 +323,52 @@ func CreateSignedCert(root, parent, serial, name, commonName string) { } } +// CreateSignedSvid creates a new SVID signed by the provided parent, +// with the provided serial number, name, common name, and SPIFFE ID. +// name is the file name to use. Common Name is the certificate common name. +func CreateSignedSvid(root, parent, serial, name, commonName string, spiffeId *url.URL) { + log.Infof("Creating signed cert and key %v (%v)", commonName, spiffeId) + + caKeyPath := path.Join(root, parent+"-key.pem") + caCertPath := path.Join(root, parent+"-cert.pem") + keyPath := path.Join(root, name+"-key.pem") + certPath := path.Join(root, name+"-cert.pem") + + caKey, err := loadKey(caKeyPath) + if err != nil { + log.Fatal(err) + } + caCert, err := loadCert(caCertPath) + if err != nil { + log.Fatal(err) + } + + priv, err := generateKey() + if err != nil { + log.Fatal(err) + } + + err = saveKey(priv, keyPath) + if err != nil { + log.Fatal(err) + } + + serialNr, err := strconv.ParseInt(serial, 10, 64) + if err != nil { + log.Fatal(err) + } + + leaf, err := signSvid(caCert, caKey, publicKey(priv), commonName, serialNr, false, spiffeId) + if err != nil { + log.Fatal(err) + } + + err = saveCert(leaf, certPath) + if err != nil { + log.Fatal(err) + } +} + // CreateCRL creates a new empty certificate revocation list // for the provided parent func CreateCRL(root, parent string) { diff --git a/go/vt/vtgate/grpcvtgateservice/server.go b/go/vt/vtgate/grpcvtgateservice/server.go index 7baff6cefe8..5757779cc81 100644 --- a/go/vt/vtgate/grpcvtgateservice/server.go +++ b/go/vt/vtgate/grpcvtgateservice/server.go @@ -105,11 +105,27 @@ func immediateCallerIdFromStaticAuthentication(ctx context.Context) (string, []s return "", nil } +// immediateCallerIdFromSPIFFEAuthentication extracts the SPIFFE ID of the current +// authentication context and returns that to the caller. It does not currently provide +// any security groups. +func immediateCallerIdFromSPIFFEAuthentication(ctx context.Context) (string, []string) { + if immediateUrl := servenv.SPIFFEIdFromContext(ctx); immediateUrl.Scheme == "spiffe" { + return immediateUrl.String(), nil + } + + return "", nil +} + // withCallerIDContext creates a context that extracts what we need // from the incoming call and can be forwarded for use when talking to vttablet. func withCallerIDContext(ctx context.Context, effectiveCallerID *vtrpcpb.CallerID) context.Context { + // The SPIFFE ID (if using SPIFFE authentication) + immediate, securityGroups := immediateCallerIdFromSPIFFEAuthentication(ctx) + // The client cert common name (if using mTLS) - immediate, securityGroups := immediateCallerIDFromCert(ctx) + if immediate == "" { + immediate, securityGroups = immediateCallerIDFromCert(ctx) + } // The effective caller id (if --grpc_use_effective_callerid=true) if immediate == "" && useEffective && effectiveCallerID != nil { diff --git a/go/vt/vttest/vtprocess.go b/go/vt/vttest/vtprocess.go index a6c03176f1b..613c73bebd9 100644 --- a/go/vt/vttest/vtprocess.go +++ b/go/vt/vttest/vtprocess.go @@ -258,6 +258,15 @@ func VtcomboProcess(environment Environment, args *Config, mysql MySQLManager) ( if servenv.GRPCAuth() == "mtls" { vt.ExtraArgs = append(vt.ExtraArgs, []string{"--grpc_auth_mode", servenv.GRPCAuth(), "--grpc_key", servenv.GRPCKey(), "--grpc_cert", servenv.GRPCCert(), "--grpc_ca", servenv.GRPCCertificateAuthority(), "--grpc_auth_mtls_allowed_substrings", servenv.ClientCertSubstrings()}...) } + if servenv.GRPCAuth() == "spiffe" { + vt.ExtraArgs = append(vt.ExtraArgs, []string{ + "--grpc_auth_mode", servenv.GRPCAuth(), + "--grpc_key", servenv.GRPCKey(), + "--grpc_cert", servenv.GRPCCert(), + "--grpc_ca", servenv.GRPCCertificateAuthority(), + "--grpc_auth_spiffe_allowed_trust_domains", servenv.SPIFFETrustDomains(), + }...) + } if args.VSchemaDDLAuthorizedUsers != "" { vt.ExtraArgs = append(vt.ExtraArgs, []string{"--vschema_ddl_authorized_users", args.VSchemaDDLAuthorizedUsers}...) }